@assistant-ui/react 0.14.14 → 0.14.16

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 (207) hide show
  1. package/README.md +5 -1
  2. package/dist/client/ExternalThread.d.ts +2 -12
  3. package/dist/client/ExternalThread.d.ts.map +1 -1
  4. package/dist/client/ExternalThread.js +30 -29
  5. package/dist/client/ExternalThread.js.map +1 -1
  6. package/dist/client/InMemoryThreadList.d.ts.map +1 -1
  7. package/dist/client/InMemoryThreadList.js +11 -10
  8. package/dist/client/InMemoryThreadList.js.map +1 -1
  9. package/dist/client/SingleThreadList.d.ts.map +1 -1
  10. package/dist/client/SingleThreadList.js +9 -8
  11. package/dist/client/SingleThreadList.js.map +1 -1
  12. package/dist/context/providers/ThreadViewportProvider.js +1 -1
  13. package/dist/context/providers/ThreadViewportProvider.js.map +1 -1
  14. package/dist/context/react/ThreadViewportContext.js +1 -1
  15. package/dist/context/react/utils/createContextHook.js +1 -1
  16. package/dist/context/react/utils/ensureBinding.js.map +1 -1
  17. package/dist/context/react/utils/useRuntimeState.js +1 -1
  18. package/dist/context/stores/ThreadViewport.js.map +1 -1
  19. package/dist/devtools/DevToolsHooks.js.map +1 -1
  20. package/dist/index.d.ts +4 -4
  21. package/dist/index.js +3 -3
  22. package/dist/legacy-runtime/AssistantRuntimeProvider.js +1 -1
  23. package/dist/legacy-runtime/cloud/auiV0.js +1 -1
  24. package/dist/legacy-runtime/hooks/AssistantContext.js.map +1 -1
  25. package/dist/legacy-runtime/hooks/AttachmentContext.js.map +1 -1
  26. package/dist/legacy-runtime/hooks/ComposerContext.js.map +1 -1
  27. package/dist/legacy-runtime/hooks/MessageContext.js.map +1 -1
  28. package/dist/legacy-runtime/hooks/MessagePartContext.js.map +1 -1
  29. package/dist/legacy-runtime/hooks/ThreadContext.js +1 -1
  30. package/dist/legacy-runtime/hooks/ThreadContext.js.map +1 -1
  31. package/dist/legacy-runtime/hooks/ThreadListItemContext.js.map +1 -1
  32. package/dist/legacy-runtime/runtime-cores/assistant-transport/commandQueue.js +1 -1
  33. package/dist/legacy-runtime/runtime-cores/assistant-transport/replayBoundaryStream.js +1 -1
  34. package/dist/legacy-runtime/runtime-cores/assistant-transport/runManager.js +1 -1
  35. package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.js +1 -1
  36. package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.js.map +1 -1
  37. package/dist/legacy-runtime/runtime-cores/assistant-transport/useConvertedState.js +1 -1
  38. package/dist/legacy-runtime/runtime-cores/assistant-transport/useLatestRef.js +1 -1
  39. package/dist/mcp-apps/McpAppRenderer.d.ts.map +1 -1
  40. package/dist/mcp-apps/McpAppRenderer.js +7 -7
  41. package/dist/mcp-apps/McpAppRenderer.js.map +1 -1
  42. package/dist/mcp-apps/McpAppsRemoteHost.d.ts.map +1 -1
  43. package/dist/mcp-apps/McpAppsRemoteHost.js +5 -4
  44. package/dist/mcp-apps/McpAppsRemoteHost.js.map +1 -1
  45. package/dist/mcp-apps/app-frame.d.ts +1 -1
  46. package/dist/mcp-apps/app-frame.d.ts.map +1 -1
  47. package/dist/mcp-apps/app-frame.js +82 -104
  48. package/dist/mcp-apps/app-frame.js.map +1 -1
  49. package/dist/mcp-apps/bridge.d.ts +3 -3
  50. package/dist/mcp-apps/bridge.d.ts.map +1 -1
  51. package/dist/mcp-apps/bridge.js +35 -10
  52. package/dist/mcp-apps/bridge.js.map +1 -1
  53. package/dist/mcp-apps/types.d.ts +2 -12
  54. package/dist/mcp-apps/types.d.ts.map +1 -1
  55. package/dist/mcp-apps/types.js.map +1 -1
  56. package/dist/model-context/frame/useAssistantFrameHost.js +1 -1
  57. package/dist/model-context/makeAssistantVisible.js +1 -1
  58. package/dist/model-context/makeAssistantVisible.js.map +1 -1
  59. package/dist/primitives/actionBar/ActionBarCopy.js +1 -1
  60. package/dist/primitives/actionBar/ActionBarExportMarkdown.js +1 -1
  61. package/dist/primitives/actionBar/ActionBarExportMarkdown.js.map +1 -1
  62. package/dist/primitives/actionBar/ActionBarFeedbackNegative.js +1 -1
  63. package/dist/primitives/actionBar/ActionBarFeedbackPositive.js +1 -1
  64. package/dist/primitives/actionBar/ActionBarInteractionContext.js +1 -1
  65. package/dist/primitives/actionBar/ActionBarRoot.js +1 -1
  66. package/dist/primitives/actionBar/ActionBarStopSpeaking.js +1 -1
  67. package/dist/primitives/actionBarMore/ActionBarMoreContent.js +1 -1
  68. package/dist/primitives/actionBarMore/ActionBarMoreItem.js +1 -1
  69. package/dist/primitives/actionBarMore/ActionBarMoreRoot.js +1 -1
  70. package/dist/primitives/actionBarMore/ActionBarMoreSeparator.js +1 -1
  71. package/dist/primitives/actionBarMore/ActionBarMoreTrigger.js +1 -1
  72. package/dist/primitives/assistantModal/AssistantModalAnchor.js +1 -1
  73. package/dist/primitives/assistantModal/AssistantModalContent.js +1 -1
  74. package/dist/primitives/assistantModal/AssistantModalRoot.js +1 -1
  75. package/dist/primitives/assistantModal/AssistantModalTrigger.js +1 -1
  76. package/dist/primitives/attachment/AttachmentRemove.js +1 -1
  77. package/dist/primitives/attachment/AttachmentRemove.js.map +1 -1
  78. package/dist/primitives/attachment/AttachmentRoot.js +1 -1
  79. package/dist/primitives/attachment/AttachmentThumb.js +1 -1
  80. package/dist/primitives/branchPicker/BranchPickerRoot.js +1 -1
  81. package/dist/primitives/chainOfThought/ChainOfThoughtAccordionTrigger.js +1 -1
  82. package/dist/primitives/chainOfThought/ChainOfThoughtAccordionTrigger.js.map +1 -1
  83. package/dist/primitives/chainOfThought/ChainOfThoughtRoot.js +1 -1
  84. package/dist/primitives/composer/ComposerAddAttachment.js +1 -1
  85. package/dist/primitives/composer/ComposerAddAttachment.js.map +1 -1
  86. package/dist/primitives/composer/ComposerAttachmentDropzone.js +1 -1
  87. package/dist/primitives/composer/ComposerAttachmentDropzone.js.map +1 -1
  88. package/dist/primitives/composer/ComposerDictationTranscript.js +1 -1
  89. package/dist/primitives/composer/ComposerInput.js +1 -1
  90. package/dist/primitives/composer/ComposerInput.js.map +1 -1
  91. package/dist/primitives/composer/ComposerInputPluginContext.js +1 -1
  92. package/dist/primitives/composer/ComposerQuote.js +1 -1
  93. package/dist/primitives/composer/ComposerQuote.js.map +1 -1
  94. package/dist/primitives/composer/ComposerRoot.js +1 -1
  95. package/dist/primitives/composer/ComposerSend.js +1 -1
  96. package/dist/primitives/composer/ComposerStopDictation.js +1 -1
  97. package/dist/primitives/composer/ComposerStopDictation.js.map +1 -1
  98. package/dist/primitives/composer/trigger/TriggerPopover.js +2 -2
  99. package/dist/primitives/composer/trigger/TriggerPopover.js.map +1 -1
  100. package/dist/primitives/composer/trigger/TriggerPopoverAction.js +1 -1
  101. package/dist/primitives/composer/trigger/TriggerPopoverBack.js +1 -1
  102. package/dist/primitives/composer/trigger/TriggerPopoverCategories.js +1 -1
  103. package/dist/primitives/composer/trigger/TriggerPopoverDirective.js +1 -1
  104. package/dist/primitives/composer/trigger/TriggerPopoverItems.js +1 -1
  105. package/dist/primitives/composer/trigger/TriggerPopoverResource.d.ts.map +1 -1
  106. package/dist/primitives/composer/trigger/TriggerPopoverResource.js +8 -7
  107. package/dist/primitives/composer/trigger/TriggerPopoverResource.js.map +1 -1
  108. package/dist/primitives/composer/trigger/TriggerPopoverRootContext.js +1 -1
  109. package/dist/primitives/composer/trigger/triggerDetectionResource.d.ts.map +1 -1
  110. package/dist/primitives/composer/trigger/triggerDetectionResource.js +5 -4
  111. package/dist/primitives/composer/trigger/triggerDetectionResource.js.map +1 -1
  112. package/dist/primitives/composer/trigger/triggerKeyboardResource.d.ts.map +1 -1
  113. package/dist/primitives/composer/trigger/triggerKeyboardResource.js +8 -7
  114. package/dist/primitives/composer/trigger/triggerKeyboardResource.js.map +1 -1
  115. package/dist/primitives/composer/trigger/triggerNavigationResource.d.ts.map +1 -1
  116. package/dist/primitives/composer/trigger/triggerNavigationResource.js +13 -12
  117. package/dist/primitives/composer/trigger/triggerNavigationResource.js.map +1 -1
  118. package/dist/primitives/composer/trigger/triggerSelectionResource.d.ts.map +1 -1
  119. package/dist/primitives/composer/trigger/triggerSelectionResource.js +7 -6
  120. package/dist/primitives/composer/trigger/triggerSelectionResource.js.map +1 -1
  121. package/dist/primitives/error/ErrorMessage.js +1 -1
  122. package/dist/primitives/error/ErrorRoot.js +1 -1
  123. package/dist/primitives/message/MessagePartsGrouped.js +1 -1
  124. package/dist/primitives/message/MessagePartsGrouped.js.map +1 -1
  125. package/dist/primitives/message/MessageRoot.js +1 -1
  126. package/dist/primitives/message/MessageRoot.js.map +1 -1
  127. package/dist/primitives/messagePart/MessagePartImage.js +1 -1
  128. package/dist/primitives/messagePart/MessagePartText.js +1 -1
  129. package/dist/primitives/queueItem/QueueItemRemove.js +1 -1
  130. package/dist/primitives/queueItem/QueueItemRemove.js.map +1 -1
  131. package/dist/primitives/queueItem/QueueItemSteer.js +1 -1
  132. package/dist/primitives/queueItem/QueueItemSteer.js.map +1 -1
  133. package/dist/primitives/queueItem/QueueItemText.js +1 -1
  134. package/dist/primitives/reasoning/useScrollLock.js +1 -1
  135. package/dist/primitives/reasoning/useScrollLock.js.map +1 -1
  136. package/dist/primitives/selectionToolbar/SelectionToolbarQuote.js +1 -1
  137. package/dist/primitives/selectionToolbar/SelectionToolbarQuote.js.map +1 -1
  138. package/dist/primitives/selectionToolbar/SelectionToolbarRoot.js +1 -1
  139. package/dist/primitives/selectionToolbar/SelectionToolbarRoot.js.map +1 -1
  140. package/dist/primitives/suggestion/SuggestionDescription.js +1 -1
  141. package/dist/primitives/suggestion/SuggestionTitle.js +1 -1
  142. package/dist/primitives/suggestion/SuggestionTrigger.js +1 -1
  143. package/dist/primitives/suggestion/SuggestionTrigger.js.map +1 -1
  144. package/dist/primitives/thread/ThreadRoot.js +1 -1
  145. package/dist/primitives/thread/ThreadScrollToBottom.js +1 -1
  146. package/dist/primitives/thread/ThreadScrollToBottom.js.map +1 -1
  147. package/dist/primitives/thread/ThreadViewport.js +1 -1
  148. package/dist/primitives/thread/ThreadViewport.js.map +1 -1
  149. package/dist/primitives/thread/ThreadViewportFooter.js +1 -1
  150. package/dist/primitives/thread/ThreadViewportFooter.js.map +1 -1
  151. package/dist/primitives/thread/topAnchor/topAnchorTurn.js.map +1 -1
  152. package/dist/primitives/thread/topAnchor/topAnchorUtils.js.map +1 -1
  153. package/dist/primitives/thread/topAnchor/useTopAnchorReserve.js +1 -1
  154. package/dist/primitives/thread/useThreadViewportAutoScroll.js +1 -1
  155. package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
  156. package/dist/primitives/threadList/ThreadListNew.js +1 -1
  157. package/dist/primitives/threadList/ThreadListRoot.js +1 -1
  158. package/dist/primitives/threadListItem/ThreadListItemRoot.js +1 -1
  159. package/dist/primitives/threadListItemMore/ThreadListItemMoreContent.js +1 -1
  160. package/dist/primitives/threadListItemMore/ThreadListItemMoreItem.js +1 -1
  161. package/dist/primitives/threadListItemMore/ThreadListItemMoreSeparator.js +1 -1
  162. package/dist/primitives/threadListItemMore/ThreadListItemMoreTrigger.js +1 -1
  163. package/dist/sandbox-host/SandboxHost.d.ts +50 -0
  164. package/dist/sandbox-host/SandboxHost.d.ts.map +1 -0
  165. package/dist/sandbox-host/SandboxHost.js +85 -0
  166. package/dist/sandbox-host/SandboxHost.js.map +1 -0
  167. package/dist/unstable/useMentionAdapter.js +1 -1
  168. package/dist/unstable/useMentionAdapter.js.map +1 -1
  169. package/dist/unstable/useSlashCommandAdapter.js +1 -1
  170. package/dist/unstable/useSlashCommandAdapter.js.map +1 -1
  171. package/dist/utils/Primitive.js +1 -1
  172. package/dist/utils/createActionButton.js +1 -1
  173. package/dist/utils/createActionButton.js.map +1 -1
  174. package/dist/utils/hooks/useManagedRef.js +1 -1
  175. package/dist/utils/hooks/useMediaQuery.js +1 -1
  176. package/dist/utils/hooks/useMediaQuery.js.map +1 -1
  177. package/dist/utils/hooks/useOnResizeContent.js +1 -1
  178. package/dist/utils/hooks/useOnScrollToBottom.js +1 -1
  179. package/dist/utils/hooks/useSizeHandle.js +1 -1
  180. package/dist/utils/json/is-json.js.map +1 -1
  181. package/dist/utils/smooth/SmoothContext.js +1 -1
  182. package/dist/utils/smooth/SmoothContext.js.map +1 -1
  183. package/dist/utils/smooth/useSmooth.js +1 -1
  184. package/dist/utils/smooth/useSmooth.js.map +1 -1
  185. package/dist/utils/useToolArgsFieldStatus.d.ts +2 -2
  186. package/dist/utils/useToolArgsFieldStatus.d.ts.map +1 -1
  187. package/package.json +48 -40
  188. package/src/client/ExternalThread.ts +484 -515
  189. package/src/client/InMemoryThreadList.ts +153 -162
  190. package/src/client/SingleThreadList.ts +87 -84
  191. package/src/context/providers/ThreadViewportProvider.tsx +2 -2
  192. package/src/index.ts +8 -1
  193. package/src/mcp-apps/McpAppRenderer.tsx +28 -35
  194. package/src/mcp-apps/McpAppsRemoteHost.ts +25 -24
  195. package/src/mcp-apps/app-frame.tsx +100 -141
  196. package/src/mcp-apps/bridge.test.ts +100 -60
  197. package/src/mcp-apps/bridge.ts +43 -21
  198. package/src/mcp-apps/types.ts +2 -12
  199. package/src/primitives/composer/trigger/TriggerPopover.tsx +1 -1
  200. package/src/primitives/composer/trigger/TriggerPopoverResource.ts +75 -76
  201. package/src/primitives/composer/trigger/triggerDetectionResource.ts +6 -5
  202. package/src/primitives/composer/trigger/triggerKeyboardResource.ts +9 -13
  203. package/src/primitives/composer/trigger/triggerNavigationResource.ts +14 -19
  204. package/src/primitives/composer/trigger/triggerSelectionResource.ts +8 -7
  205. package/src/sandbox-host/SandboxHost.test.tsx +231 -0
  206. package/src/sandbox-host/SandboxHost.tsx +185 -0
  207. package/src/tests/local-runtime-queue.test.tsx +305 -0
@@ -0,0 +1,231 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from "react";
3
+ import { createRoot, type Root } from "react-dom/client";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+
6
+ const { renderHtmlMock } = vi.hoisted(() => ({ renderHtmlMock: vi.fn() }));
7
+
8
+ vi.mock("safe-content-frame", () => ({
9
+ SafeContentFrame: class {
10
+ renderHtml = renderHtmlMock;
11
+ },
12
+ }));
13
+
14
+ import {
15
+ SandboxHost,
16
+ isSandboxFrameMessage,
17
+ type SandboxBridge,
18
+ type SandboxHostApi,
19
+ } from "./SandboxHost";
20
+
21
+ const validData = { jsonrpc: "2.0", method: "x" };
22
+
23
+ function makeFrame() {
24
+ const iframe = document.createElement("iframe");
25
+ document.body.appendChild(iframe);
26
+ return { iframe, origin: "https://app.example" };
27
+ }
28
+
29
+ function fakeRendered() {
30
+ const iframe = document.createElement("iframe");
31
+ document.body.appendChild(iframe);
32
+ return {
33
+ iframe,
34
+ origin: "https://fake.scf.test",
35
+ sendMessage: vi.fn(),
36
+ dispose: vi.fn(),
37
+ fullyLoadedPromiseWithTimeout: vi.fn(),
38
+ };
39
+ }
40
+
41
+ const flush = () =>
42
+ act(async () => {
43
+ await new Promise((r) => setTimeout(r, 0));
44
+ });
45
+
46
+ describe("isSandboxFrameMessage", () => {
47
+ it("accepts a message from the frame's contentWindow at its origin", () => {
48
+ const frame = makeFrame();
49
+ const event = new MessageEvent("message", {
50
+ data: validData,
51
+ origin: frame.origin,
52
+ source: frame.iframe.contentWindow,
53
+ });
54
+ expect(isSandboxFrameMessage(event, frame)).toBe(true);
55
+ });
56
+
57
+ it("rejects a message from a different origin", () => {
58
+ const frame = makeFrame();
59
+ const event = new MessageEvent("message", {
60
+ data: validData,
61
+ origin: "https://attacker.example",
62
+ source: frame.iframe.contentWindow,
63
+ });
64
+ expect(isSandboxFrameMessage(event, frame)).toBe(false);
65
+ });
66
+
67
+ it("rejects a message from a different source window", () => {
68
+ const frame = makeFrame();
69
+ const event = new MessageEvent("message", {
70
+ data: validData,
71
+ origin: frame.origin,
72
+ source: window,
73
+ });
74
+ expect(isSandboxFrameMessage(event, frame)).toBe(false);
75
+ });
76
+ });
77
+
78
+ describe("SandboxHost", () => {
79
+ let container: HTMLDivElement;
80
+ let root: Root;
81
+
82
+ beforeEach(() => {
83
+ container = document.createElement("div");
84
+ document.body.appendChild(container);
85
+ root = createRoot(container);
86
+ renderHtmlMock.mockReset();
87
+ });
88
+
89
+ afterEach(() => {
90
+ act(() => {
91
+ try {
92
+ root.unmount();
93
+ } catch {
94
+ // already unmounted by the test
95
+ }
96
+ });
97
+ container.remove();
98
+ });
99
+
100
+ it("delivers only frame-validated messages to the bridge", async () => {
101
+ const rendered = fakeRendered();
102
+ renderHtmlMock.mockResolvedValue(rendered);
103
+ const onMessage = vi.fn();
104
+ const bridge: SandboxBridge = { onMessage, dispose: vi.fn() };
105
+
106
+ await act(async () => {
107
+ root.render(
108
+ <SandboxHost
109
+ content={{ html: "" }}
110
+ contentKey="k"
111
+ createBridge={() => bridge}
112
+ />,
113
+ );
114
+ });
115
+ await flush();
116
+
117
+ window.dispatchEvent(
118
+ new MessageEvent("message", {
119
+ data: validData,
120
+ origin: rendered.origin,
121
+ source: rendered.iframe.contentWindow,
122
+ }),
123
+ );
124
+ expect(onMessage).toHaveBeenCalledTimes(1);
125
+
126
+ window.dispatchEvent(
127
+ new MessageEvent("message", {
128
+ data: validData,
129
+ origin: "https://attacker.example",
130
+ source: rendered.iframe.contentWindow,
131
+ }),
132
+ );
133
+ window.dispatchEvent(
134
+ new MessageEvent("message", {
135
+ data: validData,
136
+ origin: rendered.origin,
137
+ source: window,
138
+ }),
139
+ );
140
+ expect(onMessage).toHaveBeenCalledTimes(1);
141
+ });
142
+
143
+ it("clamps the bridge-reported height to maxHeight and ignores invalid values", async () => {
144
+ const rendered = fakeRendered();
145
+ renderHtmlMock.mockResolvedValue(rendered);
146
+ let host!: SandboxHostApi;
147
+ const bridge: SandboxBridge = { onMessage: vi.fn(), dispose: vi.fn() };
148
+
149
+ await act(async () => {
150
+ root.render(
151
+ <SandboxHost
152
+ content={{ html: "" }}
153
+ contentKey="k"
154
+ maxHeight={800}
155
+ createBridge={(_frame, h) => {
156
+ host = h;
157
+ return bridge;
158
+ }}
159
+ />,
160
+ );
161
+ });
162
+ await flush();
163
+
164
+ const div = container.firstElementChild as HTMLDivElement;
165
+ await act(async () => host.setHeight(200));
166
+ expect(div.style.height).toBe("200px");
167
+ await act(async () => host.setHeight(5000));
168
+ expect(div.style.height).toBe("800px");
169
+ await act(async () => host.setHeight(0));
170
+ expect(div.style.height).toBe("800px");
171
+ });
172
+
173
+ it("disposes the bridge before the frame and detaches the listener on unmount", async () => {
174
+ const rendered = fakeRendered();
175
+ renderHtmlMock.mockResolvedValue(rendered);
176
+ const order: string[] = [];
177
+ const onMessage = vi.fn();
178
+ const bridge: SandboxBridge = {
179
+ onMessage,
180
+ dispose: vi.fn(() => order.push("bridge")),
181
+ };
182
+ rendered.dispose = vi.fn(() => order.push("frame"));
183
+
184
+ await act(async () => {
185
+ root.render(
186
+ <SandboxHost
187
+ content={{ html: "" }}
188
+ contentKey="k"
189
+ createBridge={() => bridge}
190
+ />,
191
+ );
192
+ });
193
+ await flush();
194
+
195
+ await act(async () => {
196
+ root.unmount();
197
+ });
198
+
199
+ expect(order).toEqual(["bridge", "frame"]);
200
+
201
+ onMessage.mockClear();
202
+ window.dispatchEvent(
203
+ new MessageEvent("message", {
204
+ data: validData,
205
+ origin: rendered.origin,
206
+ source: rendered.iframe.contentWindow,
207
+ }),
208
+ );
209
+ expect(onMessage).not.toHaveBeenCalled();
210
+ });
211
+
212
+ it("calls onError when rendering rejects", async () => {
213
+ renderHtmlMock.mockRejectedValue(new Error("boom"));
214
+ const onError = vi.fn();
215
+
216
+ await act(async () => {
217
+ root.render(
218
+ <SandboxHost
219
+ content={{ html: "" }}
220
+ contentKey="k"
221
+ createBridge={() => ({ onMessage: vi.fn(), dispose: vi.fn() })}
222
+ onError={onError}
223
+ />,
224
+ );
225
+ });
226
+ await flush();
227
+
228
+ expect(onError).toHaveBeenCalledTimes(1);
229
+ expect(onError.mock.calls[0]![0].message).toBe("boom");
230
+ });
231
+ });
@@ -0,0 +1,185 @@
1
+ "use client";
2
+
3
+ import { type CSSProperties, useEffect, useRef, useState } from "react";
4
+ import {
5
+ type RenderedFrame,
6
+ SafeContentFrame,
7
+ type SandboxOption,
8
+ } from "safe-content-frame";
9
+
10
+ const DEFAULT_PRODUCT = "assistant-ui-sandbox";
11
+ const DEFAULT_MAX_HEIGHT = 800;
12
+
13
+ export type SandboxHostConfig = {
14
+ sandbox?: SandboxOption[];
15
+ useShadowDom?: boolean;
16
+ enableBrowserCaching?: boolean;
17
+ salt?: string;
18
+ product?: string;
19
+ className?: string;
20
+ style?: CSSProperties;
21
+ unsafeDocumentWrite?: boolean;
22
+ };
23
+
24
+ export type SandboxHostFrame = Pick<
25
+ RenderedFrame,
26
+ "iframe" | "origin" | "sendMessage"
27
+ >;
28
+
29
+ export type SandboxHostApi = {
30
+ setHeight: (height: number) => void;
31
+ };
32
+
33
+ export type SandboxBridge = {
34
+ onMessage: (event: MessageEvent) => void;
35
+ dispose: () => void;
36
+ };
37
+
38
+ export type SandboxContent = { html: string };
39
+
40
+ export type SandboxHostProps = {
41
+ content: SandboxContent;
42
+ contentKey: string;
43
+ sandbox?: SandboxHostConfig | undefined;
44
+ maxHeight?: number | undefined;
45
+ createBridge: (
46
+ frame: SandboxHostFrame,
47
+ host: SandboxHostApi,
48
+ ) => SandboxBridge;
49
+ onError?: ((error: Error) => void) | undefined;
50
+ containerProps?: Record<string, string | undefined> | undefined;
51
+ };
52
+
53
+ export function isSandboxFrameMessage(
54
+ event: MessageEvent,
55
+ frame: { iframe: HTMLIFrameElement; origin: string },
56
+ ): boolean {
57
+ return (
58
+ event.source === frame.iframe.contentWindow && event.origin === frame.origin
59
+ );
60
+ }
61
+
62
+ type LiveSnapshot = {
63
+ content: SandboxContent;
64
+ sandbox: SandboxHostConfig | undefined;
65
+ createBridge: SandboxHostProps["createBridge"];
66
+ onError: SandboxHostProps["onError"];
67
+ };
68
+
69
+ export function SandboxHost({
70
+ content,
71
+ contentKey,
72
+ sandbox,
73
+ maxHeight = DEFAULT_MAX_HEIGHT,
74
+ createBridge,
75
+ onError,
76
+ containerProps,
77
+ }: SandboxHostProps) {
78
+ const containerRef = useRef<HTMLDivElement>(null);
79
+ const [contentHeight, setContentHeight] = useState<number | undefined>(
80
+ undefined,
81
+ );
82
+
83
+ const liveRef = useRef<LiveSnapshot>(null!);
84
+ liveRef.current = { content, sandbox, createBridge, onError };
85
+
86
+ useEffect(() => {
87
+ const container = containerRef.current;
88
+ if (!container) return;
89
+
90
+ let cancelled = false;
91
+ let frame: RenderedFrame | null = null;
92
+ let bridge: SandboxBridge | null = null;
93
+ let onMessage: ((event: MessageEvent) => void) | null = null;
94
+
95
+ const { content: liveContent, sandbox: sb } = liveRef.current;
96
+
97
+ const scf = new SafeContentFrame(sb?.product ?? DEFAULT_PRODUCT, {
98
+ ...(sb?.sandbox !== undefined && { sandbox: sb.sandbox }),
99
+ ...(sb?.useShadowDom !== undefined && { useShadowDom: sb.useShadowDom }),
100
+ ...(sb?.enableBrowserCaching !== undefined && {
101
+ enableBrowserCaching: sb.enableBrowserCaching,
102
+ }),
103
+ ...(sb?.salt !== undefined && { salt: sb.salt }),
104
+ });
105
+
106
+ const renderOpts =
107
+ sb?.unsafeDocumentWrite !== undefined
108
+ ? { unsafeDocumentWrite: sb.unsafeDocumentWrite }
109
+ : undefined;
110
+
111
+ scf
112
+ .renderHtml(liveContent.html, container, renderOpts)
113
+ .then((rendered) => {
114
+ if (cancelled) {
115
+ rendered.dispose();
116
+ return;
117
+ }
118
+ frame = rendered;
119
+
120
+ const hostApi: SandboxHostApi = {
121
+ setHeight: (height) => {
122
+ if (
123
+ typeof height === "number" &&
124
+ Number.isFinite(height) &&
125
+ height > 0
126
+ ) {
127
+ setContentHeight(height);
128
+ }
129
+ },
130
+ };
131
+
132
+ bridge = liveRef.current.createBridge(
133
+ {
134
+ iframe: rendered.iframe,
135
+ origin: rendered.origin,
136
+ sendMessage: rendered.sendMessage,
137
+ },
138
+ hostApi,
139
+ );
140
+
141
+ // Single owner of the window listener; the cross-origin guard runs
142
+ // here so the bridge only sees frame-validated messages.
143
+ onMessage = (event) => {
144
+ if (!isSandboxFrameMessage(event, rendered)) return;
145
+ bridge?.onMessage(event);
146
+ };
147
+ window.addEventListener("message", onMessage);
148
+ })
149
+ .catch((err) => {
150
+ liveRef.current.onError?.(
151
+ err instanceof Error ? err : new Error(String(err)),
152
+ );
153
+ });
154
+
155
+ return () => {
156
+ cancelled = true;
157
+ if (onMessage) {
158
+ window.removeEventListener("message", onMessage);
159
+ onMessage = null;
160
+ }
161
+ bridge?.dispose();
162
+ bridge = null;
163
+ frame?.dispose();
164
+ frame = null;
165
+ setContentHeight(undefined);
166
+ };
167
+ // oxlint-disable-next-line react/exhaustive-deps -- re-init only on contentKey change; live values flow through liveRef
168
+ }, [contentKey]);
169
+
170
+ const resolvedHeight =
171
+ contentHeight != null ? Math.min(contentHeight, maxHeight) : undefined;
172
+ const mergedStyle =
173
+ resolvedHeight != null
174
+ ? { ...sandbox?.style, height: resolvedHeight }
175
+ : sandbox?.style;
176
+
177
+ return (
178
+ <div
179
+ {...containerProps}
180
+ ref={containerRef}
181
+ className={sandbox?.className}
182
+ style={mergedStyle}
183
+ />
184
+ );
185
+ }
@@ -0,0 +1,305 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { render, act } from "@testing-library/react";
4
+ import type { FC } from "react";
5
+ import { describe, it, expect } from "vitest";
6
+ import { useAui } from "@assistant-ui/store";
7
+ import { AssistantRuntimeProvider } from "../context";
8
+ import { useLocalRuntime } from "../legacy-runtime/runtime-cores/local/useLocalRuntime";
9
+ import type { ChatModelAdapter } from "../legacy-runtime/runtime-cores/local/ChatModelAdapter";
10
+
11
+ const flush = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
12
+
13
+ const createCountingAdapter = () => {
14
+ const releases: Array<() => void> = [];
15
+ let runCount = 0;
16
+ const adapter: ChatModelAdapter = {
17
+ async *run({ abortSignal }) {
18
+ runCount++;
19
+ await new Promise<void>((resolve) => {
20
+ releases.push(resolve);
21
+ abortSignal.addEventListener("abort", () => resolve(), { once: true });
22
+ });
23
+ yield { content: [{ type: "text", text: "done" }] };
24
+ },
25
+ };
26
+ return { adapter, releases, getRunCount: () => runCount };
27
+ };
28
+
29
+ const userTexts = (aui: ReturnType<typeof useAui>) =>
30
+ aui
31
+ .thread()
32
+ .getState()
33
+ .messages.filter((m) => m.role === "user")
34
+ .map((m) =>
35
+ m.content.map((p) => (p.type === "text" ? p.text : "")).join(""),
36
+ );
37
+
38
+ const renderWithRuntime = (adapter: ChatModelAdapter, enableQueue: boolean) => {
39
+ const captured: { aui?: ReturnType<typeof useAui> } = {};
40
+ const Capture: FC = () => {
41
+ captured.aui = useAui();
42
+ return null;
43
+ };
44
+ const App: FC = () => {
45
+ const runtime = useLocalRuntime(adapter, {
46
+ unstable_enableMessageQueue: enableQueue,
47
+ });
48
+ return (
49
+ <AssistantRuntimeProvider runtime={runtime}>
50
+ <Capture />
51
+ </AssistantRuntimeProvider>
52
+ );
53
+ };
54
+ render(<App />);
55
+ return captured.aui!;
56
+ };
57
+
58
+ const send = async (aui: ReturnType<typeof useAui>, text: string) => {
59
+ await act(async () => {
60
+ aui.thread().composer().setText(text);
61
+ aui.thread().composer().send();
62
+ await flush();
63
+ });
64
+ };
65
+
66
+ describe("local runtime message queue", () => {
67
+ it("buffers a send while running and flushes it when the run ends", async () => {
68
+ const { adapter, releases } = createCountingAdapter();
69
+ const aui = renderWithRuntime(adapter, true);
70
+
71
+ await send(aui, "first");
72
+ expect(aui.thread().getState().isRunning).toBe(true);
73
+ expect(aui.thread().getState().capabilities.queue).toBe(true);
74
+
75
+ await send(aui, "second");
76
+ expect(
77
+ aui
78
+ .thread()
79
+ .composer()
80
+ .getState()
81
+ .queue.map((q) => q.prompt),
82
+ ).toEqual(["second"]);
83
+ expect(userTexts(aui)).toEqual(["first"]);
84
+
85
+ await act(async () => {
86
+ releases[0]!();
87
+ await flush();
88
+ await flush();
89
+ });
90
+ expect(aui.thread().composer().getState().queue).toEqual([]);
91
+ expect(userTexts(aui)).toContain("second");
92
+ });
93
+
94
+ it("drains two queued items in separate runs, not all at once", async () => {
95
+ const { adapter, releases, getRunCount } = createCountingAdapter();
96
+ const aui = renderWithRuntime(adapter, true);
97
+
98
+ await send(aui, "first");
99
+ expect(getRunCount()).toBe(1);
100
+
101
+ await send(aui, "a");
102
+ await send(aui, "b");
103
+ expect(aui.thread().composer().getState().queue).toHaveLength(2);
104
+
105
+ await act(async () => {
106
+ releases[0]!();
107
+ await flush();
108
+ await flush();
109
+ });
110
+ expect(getRunCount()).toBe(2);
111
+ expect(
112
+ aui
113
+ .thread()
114
+ .composer()
115
+ .getState()
116
+ .queue.map((q) => q.prompt),
117
+ ).toEqual(["b"]);
118
+ });
119
+
120
+ it("queueItem(index).remove() drops a queued message", async () => {
121
+ const { adapter } = createCountingAdapter();
122
+ const aui = renderWithRuntime(adapter, true);
123
+
124
+ await send(aui, "first");
125
+ await send(aui, "a");
126
+ await send(aui, "b");
127
+ expect(aui.thread().composer().getState().queue).toHaveLength(2);
128
+
129
+ await act(async () => {
130
+ aui.thread().composer().queueItem({ index: 0 }).remove();
131
+ await flush();
132
+ });
133
+ const queue = aui.thread().composer().getState().queue;
134
+ expect(queue).toHaveLength(1);
135
+ expect(queue[0]!.prompt).toBe("b");
136
+ });
137
+
138
+ it("clears the queue when the run is cancelled, without flushing", async () => {
139
+ const { adapter, getRunCount } = createCountingAdapter();
140
+ const aui = renderWithRuntime(adapter, true);
141
+
142
+ await send(aui, "first");
143
+ await send(aui, "a");
144
+ await send(aui, "b");
145
+ expect(aui.thread().composer().getState().queue).toHaveLength(2);
146
+
147
+ await act(async () => {
148
+ aui.thread().cancelRun();
149
+ await flush();
150
+ await flush();
151
+ });
152
+
153
+ expect(aui.thread().composer().getState().queue).toEqual([]);
154
+ // cancelling must not start the next queued message
155
+ expect(getRunCount()).toBe(1);
156
+ });
157
+
158
+ it("applies an edit instead of queuing it, dropping pending items", async () => {
159
+ const { adapter, releases } = createCountingAdapter();
160
+ const aui = renderWithRuntime(adapter, true);
161
+
162
+ await send(aui, "first");
163
+ await act(async () => {
164
+ releases[0]!();
165
+ await flush();
166
+ });
167
+
168
+ // start a second run and queue a message behind it
169
+ await send(aui, "second");
170
+ await send(aui, "queued");
171
+ expect(aui.thread().composer().getState().queue).toHaveLength(1);
172
+
173
+ // edit the first message while the run is in progress
174
+ await act(async () => {
175
+ const message = aui.thread().message({ index: 0 });
176
+ message.composer().beginEdit();
177
+ message.composer().setText("edited");
178
+ message.composer().send();
179
+ await flush();
180
+ });
181
+
182
+ // the edit is applied (branches the thread) and the stale queue is cleared
183
+ expect(aui.thread().composer().getState().queue).toEqual([]);
184
+ });
185
+
186
+ it("buffers a send during a regenerate instead of interrupting it", async () => {
187
+ const { adapter, releases, getRunCount } = createCountingAdapter();
188
+ const aui = renderWithRuntime(adapter, true);
189
+
190
+ await send(aui, "first");
191
+ await act(async () => {
192
+ releases[0]!();
193
+ await flush();
194
+ });
195
+ expect(getRunCount()).toBe(1);
196
+
197
+ // regenerate the assistant message: a run started outside the queue
198
+ await act(async () => {
199
+ aui.thread().message({ index: 1 }).reload();
200
+ await flush();
201
+ });
202
+ expect(getRunCount()).toBe(2);
203
+ expect(aui.thread().getState().isRunning).toBe(true);
204
+
205
+ // sending now must buffer, not interrupt the regenerate
206
+ await act(async () => {
207
+ aui.thread().composer().setText("Y");
208
+ aui.thread().composer().send();
209
+ await flush();
210
+ });
211
+ expect(getRunCount()).toBe(2);
212
+ expect(
213
+ aui
214
+ .thread()
215
+ .composer()
216
+ .getState()
217
+ .queue.map((q) => q.prompt),
218
+ ).toEqual(["Y"]);
219
+ });
220
+
221
+ it("advances exactly once after a failed run, without deadlocking", async () => {
222
+ const releases: Array<() => void> = [];
223
+ let runCount = 0;
224
+ const adapter: ChatModelAdapter = {
225
+ async *run({ abortSignal }) {
226
+ runCount++;
227
+ if (runCount === 2) throw new Error("model boom");
228
+ await new Promise<void>((resolve) => {
229
+ releases.push(resolve);
230
+ abortSignal.addEventListener("abort", () => resolve(), {
231
+ once: true,
232
+ });
233
+ });
234
+ yield { content: [{ type: "text", text: "done" }] };
235
+ },
236
+ };
237
+ const aui = renderWithRuntime(adapter, true);
238
+
239
+ await send(aui, "first");
240
+ await send(aui, "a");
241
+ await send(aui, "b");
242
+ expect(aui.thread().composer().getState().queue).toHaveLength(2);
243
+
244
+ // run 1 settles -> "a" drains (run 2 throws) -> "b" drains (run 3)
245
+ await act(async () => {
246
+ releases[0]!();
247
+ await flush();
248
+ await flush();
249
+ });
250
+ expect(runCount).toBe(3);
251
+ expect(aui.thread().composer().getState().queue).toEqual([]);
252
+
253
+ // "b" is running; a new send must buffer behind it, not interrupt it
254
+ await act(async () => {
255
+ aui.thread().composer().setText("c");
256
+ aui.thread().composer().send();
257
+ await flush();
258
+ });
259
+ expect(runCount).toBe(3);
260
+ expect(
261
+ aui
262
+ .thread()
263
+ .composer()
264
+ .getState()
265
+ .queue.map((q) => q.prompt),
266
+ ).toEqual(["c"]);
267
+ });
268
+
269
+ it("does not expose the queue capability when the flag is off", async () => {
270
+ const { adapter } = createCountingAdapter();
271
+ const aui = renderWithRuntime(adapter, false);
272
+ expect(aui.thread().getState().capabilities.queue).toBe(false);
273
+ });
274
+
275
+ it("tears the queue down when the flag is toggled off at runtime", async () => {
276
+ const { adapter } = createCountingAdapter();
277
+ const captured: { aui?: ReturnType<typeof useAui> } = {};
278
+ const Capture: FC = () => {
279
+ captured.aui = useAui();
280
+ return null;
281
+ };
282
+ const App: FC<{ enabled: boolean }> = ({ enabled }) => {
283
+ const runtime = useLocalRuntime(adapter, {
284
+ unstable_enableMessageQueue: enabled,
285
+ });
286
+ return (
287
+ <AssistantRuntimeProvider runtime={runtime}>
288
+ <Capture />
289
+ </AssistantRuntimeProvider>
290
+ );
291
+ };
292
+
293
+ const { rerender } = render(<App enabled={true} />);
294
+ await act(async () => {
295
+ await flush();
296
+ });
297
+ expect(captured.aui!.thread().getState().capabilities.queue).toBe(true);
298
+
299
+ await act(async () => {
300
+ rerender(<App enabled={false} />);
301
+ await flush();
302
+ });
303
+ expect(captured.aui!.thread().getState().capabilities.queue).toBe(false);
304
+ });
305
+ });