@assistant-ui/react 0.15.18 → 0.15.20
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.
- package/README.md +7 -1
- package/dist/context/providers/ThreadViewportProvider.js +2 -2
- package/dist/context/providers/ThreadViewportProvider.js.map +1 -1
- package/dist/context/stores/ThreadViewport.d.ts.map +1 -1
- package/dist/context/stores/ThreadViewport.js +2 -1
- package/dist/context/stores/ThreadViewport.js.map +1 -1
- package/dist/hooks/useToolCallElapsed.js.map +1 -1
- package/dist/index.d.ts +5 -5
- package/dist/index.js +3 -3
- package/dist/legacy-runtime/runtime-cores/external-store/external-message-converter.d.ts +2 -2
- package/dist/legacy-runtime/runtime-cores/external-store/external-message-converter.js +2 -2
- package/dist/mcp-apps/McpAppRenderer.js +1 -1
- package/dist/mcp-apps/app-frame.d.ts.map +1 -1
- package/dist/mcp-apps/app-frame.js +18 -13
- package/dist/mcp-apps/app-frame.js.map +1 -1
- package/dist/mcp-apps/bridge.js +7 -1
- package/dist/mcp-apps/bridge.js.map +1 -1
- package/dist/model-context/frame/useAssistantFrameHost.js +20 -2
- package/dist/model-context/frame/useAssistantFrameHost.js.map +1 -1
- package/dist/primitives/assistantModal/AssistantModalRoot.d.ts.map +1 -1
- package/dist/primitives/assistantModal/AssistantModalRoot.js +47 -79
- package/dist/primitives/assistantModal/AssistantModalRoot.js.map +1 -1
- package/dist/primitives/composer/ComposerAttachmentDropzone.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerAttachmentDropzone.js +61 -138
- package/dist/primitives/composer/ComposerAttachmentDropzone.js.map +1 -1
- package/dist/primitives/composer/ComposerInput.js +3 -8
- package/dist/primitives/composer/ComposerInput.js.map +1 -1
- package/dist/primitives/composer/trigger/triggerKeyboardResource.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/triggerKeyboardResource.js +53 -61
- package/dist/primitives/composer/trigger/triggerKeyboardResource.js.map +1 -1
- package/dist/primitives/composer/trigger/triggerNavigationResource.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/triggerNavigationResource.js +127 -137
- package/dist/primitives/composer/trigger/triggerNavigationResource.js.map +1 -1
- package/dist/primitives/message/MessageParts.js +2 -14
- package/dist/primitives/message/MessageParts.js.map +1 -1
- package/dist/primitives/message/MessagePartsGrouped.d.ts +3 -3
- package/dist/primitives/message/MessagePartsGrouped.js +2 -2
- package/dist/primitives/message/MessagePartsGrouped.js.map +1 -1
- package/dist/primitives/messagePart/MessagePartText.d.ts +2 -0
- package/dist/primitives/messagePart/MessagePartText.d.ts.map +1 -1
- package/dist/primitives/messagePart/MessagePartText.js +13 -36
- package/dist/primitives/messagePart/MessagePartText.js.map +1 -1
- package/dist/primitives/reasoning/useScrollLock.js +3 -1
- package/dist/primitives/reasoning/useScrollLock.js.map +1 -1
- package/dist/sandbox-host/SandboxHost.js +23 -5
- package/dist/sandbox-host/SandboxHost.js.map +1 -1
- package/dist/unstable/useLiveCompletionAdapter.js +24 -2
- package/dist/unstable/useLiveCompletionAdapter.js.map +1 -1
- package/dist/unstable/useMentionAdapter.js +2 -2
- package/dist/unstable/useMentionAdapter.js.map +1 -1
- package/dist/unstable/useMessageStallDetection.d.ts +4 -5
- package/dist/unstable/useMessageStallDetection.d.ts.map +1 -1
- package/dist/unstable/useMessageStallDetection.js +16 -15
- package/dist/unstable/useMessageStallDetection.js.map +1 -1
- package/dist/unstable/webmcp/convertTools.d.ts.map +1 -1
- package/dist/unstable/webmcp/convertTools.js +54 -3
- package/dist/unstable/webmcp/convertTools.js.map +1 -1
- package/dist/unstable/webmcp/useWebMcpProvider.d.ts.map +1 -1
- package/dist/unstable/webmcp/useWebMcpProvider.js +2 -1
- package/dist/unstable/webmcp/useWebMcpProvider.js.map +1 -1
- package/dist/utils/Primitive.d.ts +10 -2
- package/dist/utils/Primitive.d.ts.map +1 -1
- package/dist/utils/Primitive.js +25 -6
- package/dist/utils/Primitive.js.map +1 -1
- package/dist/utils/getSelectionMessageId.d.ts.map +1 -1
- package/dist/utils/getSelectionMessageId.js +17 -3
- package/dist/utils/getSelectionMessageId.js.map +1 -1
- package/package.json +12 -12
- package/src/context/providers/ThreadViewportProvider.tsx +2 -2
- package/src/context/stores/ThreadViewport.test.ts +29 -0
- package/src/context/stores/ThreadViewport.ts +6 -3
- package/src/hooks/useToolCallElapsed.ts +3 -0
- package/src/index.ts +6 -1
- package/src/legacy-runtime/runtime-cores/external-store/external-message-converter.ts +2 -0
- package/src/mcp-apps/app-frame.test.tsx +189 -1
- package/src/mcp-apps/app-frame.tsx +31 -22
- package/src/mcp-apps/bridge.test.ts +98 -0
- package/src/mcp-apps/bridge.ts +7 -1
- package/src/model-context/frame/useAssistantFrameHost.test.tsx +42 -0
- package/src/model-context/frame/useAssistantFrameHost.ts +25 -2
- package/src/primitives/assistantModal/AssistantModalRoot.test.tsx +123 -0
- package/src/primitives/assistantModal/AssistantModalRoot.tsx +11 -31
- package/src/primitives/assistantModal/AssistantModalTrigger.test.tsx +1 -16
- package/src/primitives/composer/ComposerAttachmentDropzone.test.tsx +43 -0
- package/src/primitives/composer/ComposerAttachmentDropzone.tsx +2 -10
- package/src/primitives/composer/ComposerInput.test.tsx +22 -0
- package/src/primitives/composer/ComposerInput.tsx +5 -9
- package/src/primitives/composer/trigger/triggerKeyboardResource.ts +13 -7
- package/src/primitives/composer/trigger/triggerNavigationResource.ts +5 -3
- package/src/primitives/message/MessageParts.tsx +1 -21
- package/src/primitives/message/MessagePartsGrouped.test.tsx +93 -0
- package/src/primitives/message/MessagePartsGrouped.tsx +6 -6
- package/src/primitives/messagePart/MessagePartText.test.tsx +69 -0
- package/src/primitives/messagePart/MessagePartText.tsx +22 -9
- package/src/primitives/reasoning/useScrollLock.test.tsx +129 -0
- package/src/primitives/reasoning/useScrollLock.ts +15 -1
- package/src/primitives/thread/useThreadViewportAutoScroll.test.tsx +38 -1
- package/src/sandbox-host/SandboxHost.test.tsx +40 -0
- package/src/sandbox-host/SandboxHost.tsx +24 -4
- package/src/tests/MessageParts.rendererOptions.test.tsx +117 -0
- package/src/tests/toolCallTiming.test.tsx +55 -1
- package/src/unstable/useLiveCompletionAdapter.test.tsx +202 -1
- package/src/unstable/useLiveCompletionAdapter.ts +38 -8
- package/src/unstable/useMentionAdapter.test.tsx +47 -1
- package/src/unstable/useMentionAdapter.ts +2 -2
- package/src/unstable/useMessageStallDetection.ts +26 -19
- package/src/unstable/webmcp/convertTools.test.ts +181 -21
- package/src/unstable/webmcp/convertTools.ts +87 -7
- package/src/unstable/webmcp/useWebMcpProvider.ts +2 -4
- package/src/utils/Primitive.test.tsx +46 -1
- package/src/utils/Primitive.tsx +37 -7
- package/src/utils/getSelectionMessageId.test.ts +176 -1
- package/src/utils/getSelectionMessageId.ts +21 -3
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
SandboxHostProps,
|
|
8
8
|
} from "../sandbox-host/SandboxHost";
|
|
9
9
|
import type { CreateMcpAppBridgeOptions, McpAppBridge } from "./bridge";
|
|
10
|
+
import type * as BridgeModule from "./bridge";
|
|
10
11
|
import { MCP_APP_MIME_TYPE, type McpAppHostContext } from "./types";
|
|
11
12
|
|
|
12
13
|
const { sandboxHostMock, createMcpAppBridgeMock } = vi.hoisted(() => ({
|
|
@@ -28,7 +29,6 @@ import { McpAppFrame } from "./app-frame";
|
|
|
28
29
|
describe("McpAppFrame", () => {
|
|
29
30
|
afterEach(() => {
|
|
30
31
|
cleanup();
|
|
31
|
-
vi.clearAllMocks();
|
|
32
32
|
});
|
|
33
33
|
|
|
34
34
|
it("keeps bridge options scoped to committed renders", async () => {
|
|
@@ -100,6 +100,178 @@ describe("McpAppFrame", () => {
|
|
|
100
100
|
sandboxBridge.dispose();
|
|
101
101
|
});
|
|
102
102
|
|
|
103
|
+
it.each([
|
|
104
|
+
{ initial: [], next: ["search"], allowed: true },
|
|
105
|
+
{ initial: ["search"], next: [], allowed: false },
|
|
106
|
+
{ initial: undefined, next: ["other"], allowed: false },
|
|
107
|
+
{ initial: [], next: undefined, allowed: true },
|
|
108
|
+
])(
|
|
109
|
+
"applies replacement tool allowlists: $initial -> $next",
|
|
110
|
+
async ({ initial, next, allowed }) => {
|
|
111
|
+
const { createMcpAppBridge } =
|
|
112
|
+
await vi.importActual<typeof BridgeModule>("./bridge");
|
|
113
|
+
createMcpAppBridgeMock.mockImplementationOnce(createMcpAppBridge);
|
|
114
|
+
const captured: { createBridge?: SandboxHostProps["createBridge"] } = {};
|
|
115
|
+
sandboxHostMock.mockImplementation((props: SandboxHostProps) => {
|
|
116
|
+
captured.createBridge ??= props.createBridge;
|
|
117
|
+
return null;
|
|
118
|
+
});
|
|
119
|
+
const callTool = vi.fn(() => ({ content: [] }));
|
|
120
|
+
const view = (allowedTools: readonly string[] | undefined) => (
|
|
121
|
+
<McpAppFrame
|
|
122
|
+
app={{ resourceUri: "ui://example/widget" }}
|
|
123
|
+
resource={{
|
|
124
|
+
uri: "ui://example/widget",
|
|
125
|
+
mimeType: MCP_APP_MIME_TYPE,
|
|
126
|
+
html: "",
|
|
127
|
+
}}
|
|
128
|
+
handlers={
|
|
129
|
+
allowedTools === undefined
|
|
130
|
+
? { callTool }
|
|
131
|
+
: { allowedTools, callTool }
|
|
132
|
+
}
|
|
133
|
+
/>
|
|
134
|
+
);
|
|
135
|
+
const rendered = render(view(initial));
|
|
136
|
+
if (!captured.createBridge) throw new Error("Frame did not mount");
|
|
137
|
+
const sendMessage = vi.fn();
|
|
138
|
+
const bridge = captured.createBridge(
|
|
139
|
+
{
|
|
140
|
+
iframe: document.createElement("iframe"),
|
|
141
|
+
origin: "https://widget.example",
|
|
142
|
+
sendMessage,
|
|
143
|
+
},
|
|
144
|
+
{ setHeight: vi.fn() },
|
|
145
|
+
);
|
|
146
|
+
try {
|
|
147
|
+
rendered.rerender(view(next));
|
|
148
|
+
bridge.onMessage(
|
|
149
|
+
new MessageEvent("message", {
|
|
150
|
+
data: {
|
|
151
|
+
jsonrpc: "2.0",
|
|
152
|
+
id: 1,
|
|
153
|
+
method: "tools/call",
|
|
154
|
+
params: { name: "search" },
|
|
155
|
+
},
|
|
156
|
+
}),
|
|
157
|
+
);
|
|
158
|
+
await Promise.resolve();
|
|
159
|
+
expect(sendMessage).toHaveBeenCalledWith(
|
|
160
|
+
expect.objectContaining(
|
|
161
|
+
allowed
|
|
162
|
+
? { id: 1, result: { content: [] } }
|
|
163
|
+
: { id: 1, error: expect.objectContaining({ code: -32602 }) },
|
|
164
|
+
),
|
|
165
|
+
);
|
|
166
|
+
expect(callTool).toHaveBeenCalledTimes(allowed ? 1 : 0);
|
|
167
|
+
} finally {
|
|
168
|
+
bridge.dispose();
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
it("forwards size changes to the sandbox host and live handler", () => {
|
|
174
|
+
let createBridge: SandboxHostProps["createBridge"] | null = null;
|
|
175
|
+
sandboxHostMock.mockImplementation((props: SandboxHostProps) => {
|
|
176
|
+
createBridge ??= props.createBridge;
|
|
177
|
+
return null;
|
|
178
|
+
});
|
|
179
|
+
const bridge: McpAppBridge = {
|
|
180
|
+
onMessage: vi.fn(),
|
|
181
|
+
dispose: vi.fn(),
|
|
182
|
+
notifyToolInput: vi.fn(),
|
|
183
|
+
notifyToolResult: vi.fn(),
|
|
184
|
+
notifyHostContextChanged: vi.fn(),
|
|
185
|
+
};
|
|
186
|
+
createMcpAppBridgeMock.mockReturnValue(bridge);
|
|
187
|
+
const initialOnSizeChange = vi.fn();
|
|
188
|
+
const replacementOnSizeChange = vi.fn();
|
|
189
|
+
const view = (onSizeChange: typeof initialOnSizeChange) => (
|
|
190
|
+
<McpAppFrame
|
|
191
|
+
app={{ resourceUri: "ui://example/widget" }}
|
|
192
|
+
resource={{
|
|
193
|
+
uri: "ui://example/widget",
|
|
194
|
+
mimeType: MCP_APP_MIME_TYPE,
|
|
195
|
+
html: "",
|
|
196
|
+
}}
|
|
197
|
+
handlers={{ onSizeChange }}
|
|
198
|
+
/>
|
|
199
|
+
);
|
|
200
|
+
const rendered = render(view(initialOnSizeChange));
|
|
201
|
+
|
|
202
|
+
const setHeight = vi.fn();
|
|
203
|
+
const sandboxBridge = createBridge!(
|
|
204
|
+
{
|
|
205
|
+
iframe: document.createElement("iframe"),
|
|
206
|
+
origin: "https://widget.example",
|
|
207
|
+
sendMessage: vi.fn(),
|
|
208
|
+
},
|
|
209
|
+
{ setHeight },
|
|
210
|
+
);
|
|
211
|
+
const options = createMcpAppBridgeMock.mock
|
|
212
|
+
.calls[0]![0] as CreateMcpAppBridgeOptions;
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
const initialSize = { width: 640, height: 360 };
|
|
216
|
+
options.handlers?.onSizeChange?.(initialSize);
|
|
217
|
+
expect(setHeight).toHaveBeenCalledWith(360);
|
|
218
|
+
expect(initialOnSizeChange).toHaveBeenCalledWith(initialSize);
|
|
219
|
+
|
|
220
|
+
rendered.rerender(view(replacementOnSizeChange));
|
|
221
|
+
const replacementSize = { width: 800, height: 480 };
|
|
222
|
+
options.handlers?.onSizeChange?.(replacementSize);
|
|
223
|
+
expect(setHeight).toHaveBeenNthCalledWith(2, 480);
|
|
224
|
+
expect(initialOnSizeChange).toHaveBeenCalledOnce();
|
|
225
|
+
expect(replacementOnSizeChange).toHaveBeenCalledOnce();
|
|
226
|
+
expect(replacementOnSizeChange).toHaveBeenCalledWith(replacementSize);
|
|
227
|
+
|
|
228
|
+
const widthOnlySize = { width: 720 };
|
|
229
|
+
options.handlers?.onSizeChange?.(widthOnlySize);
|
|
230
|
+
expect(setHeight).toHaveBeenCalledTimes(2);
|
|
231
|
+
expect(replacementOnSizeChange).toHaveBeenCalledWith(widthOnlySize);
|
|
232
|
+
} finally {
|
|
233
|
+
sandboxBridge.dispose();
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("disposes the underlying bridge with the sandbox bridge", () => {
|
|
238
|
+
let createBridge: SandboxHostProps["createBridge"] | null = null;
|
|
239
|
+
sandboxHostMock.mockImplementation((props: SandboxHostProps) => {
|
|
240
|
+
createBridge ??= props.createBridge;
|
|
241
|
+
return null;
|
|
242
|
+
});
|
|
243
|
+
const bridge: McpAppBridge = {
|
|
244
|
+
onMessage: vi.fn(),
|
|
245
|
+
dispose: vi.fn(),
|
|
246
|
+
notifyToolInput: vi.fn(),
|
|
247
|
+
notifyToolResult: vi.fn(),
|
|
248
|
+
notifyHostContextChanged: vi.fn(),
|
|
249
|
+
};
|
|
250
|
+
createMcpAppBridgeMock.mockReturnValue(bridge);
|
|
251
|
+
render(
|
|
252
|
+
<McpAppFrame
|
|
253
|
+
app={{ resourceUri: "ui://example/widget" }}
|
|
254
|
+
resource={{
|
|
255
|
+
uri: "ui://example/widget",
|
|
256
|
+
mimeType: MCP_APP_MIME_TYPE,
|
|
257
|
+
html: "",
|
|
258
|
+
}}
|
|
259
|
+
/>,
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
const sandboxBridge = createBridge!(
|
|
263
|
+
{
|
|
264
|
+
iframe: document.createElement("iframe"),
|
|
265
|
+
origin: "https://widget.example",
|
|
266
|
+
sendMessage: vi.fn(),
|
|
267
|
+
},
|
|
268
|
+
{ setHeight: vi.fn() },
|
|
269
|
+
);
|
|
270
|
+
sandboxBridge.dispose();
|
|
271
|
+
|
|
272
|
+
expect(bridge.dispose).toHaveBeenCalledOnce();
|
|
273
|
+
});
|
|
274
|
+
|
|
103
275
|
it("only notifies the widget when host context actually changes", () => {
|
|
104
276
|
let createBridge: SandboxHostProps["createBridge"] | null = null;
|
|
105
277
|
sandboxHostMock.mockImplementation((props: SandboxHostProps) => {
|
|
@@ -159,6 +331,22 @@ describe("McpAppFrame", () => {
|
|
|
159
331
|
availableDisplayModes: ["inline", "pip"],
|
|
160
332
|
});
|
|
161
333
|
|
|
334
|
+
// A hole is not a value, in either operand order.
|
|
335
|
+
const sparseModes: ("inline" | "pip")[] = new Array(2);
|
|
336
|
+
sparseModes[1] = "pip";
|
|
337
|
+
rendered.rerender(
|
|
338
|
+
view({ displayMode: "fullscreen", availableDisplayModes: sparseModes }),
|
|
339
|
+
);
|
|
340
|
+
expect(bridge.notifyHostContextChanged).toHaveBeenCalledTimes(2);
|
|
341
|
+
|
|
342
|
+
rendered.rerender(
|
|
343
|
+
view({
|
|
344
|
+
displayMode: "fullscreen",
|
|
345
|
+
availableDisplayModes: ["inline", "pip"],
|
|
346
|
+
}),
|
|
347
|
+
);
|
|
348
|
+
expect(bridge.notifyHostContextChanged).toHaveBeenCalledTimes(3);
|
|
349
|
+
|
|
162
350
|
sandboxBridge.dispose();
|
|
163
351
|
});
|
|
164
352
|
it("cancels a queued host context that returns to the delivered value", () => {
|
|
@@ -42,12 +42,12 @@ const isSameHostContext = (a: unknown, b: unknown, depth = 0): boolean => {
|
|
|
42
42
|
if (Object.is(a, b)) return true;
|
|
43
43
|
if (depth > 100) return false;
|
|
44
44
|
if (Array.isArray(a) || Array.isArray(b)) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
a
|
|
49
|
-
|
|
50
|
-
|
|
45
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length)
|
|
46
|
+
return false;
|
|
47
|
+
for (let i = 0; i < a.length; i++) {
|
|
48
|
+
if (!isSameHostContext(a[i], b[i], depth + 1)) return false;
|
|
49
|
+
}
|
|
50
|
+
return true;
|
|
51
51
|
}
|
|
52
52
|
if (!isPlainObject(a) || !isPlainObject(b)) return false;
|
|
53
53
|
const aKeys = Object.keys(a);
|
|
@@ -96,25 +96,33 @@ type LiveSnapshot = {
|
|
|
96
96
|
output: unknown;
|
|
97
97
|
};
|
|
98
98
|
|
|
99
|
+
type FrameLifecycle = {
|
|
100
|
+
onInitialized: () => void;
|
|
101
|
+
onSizeChange: (params: { width?: number; height?: number }) => void;
|
|
102
|
+
};
|
|
103
|
+
|
|
99
104
|
// Proxy each per-call handler through liveRef so the bridge always dispatches
|
|
100
105
|
// to the latest handler reference (e.g. inline callbacks closing over state).
|
|
101
106
|
// Capability presence is snapshot at mount: a handler added later requires a
|
|
102
107
|
// remount (keyed on resource URI) to expose the capability to the widget.
|
|
108
|
+
// allowedTools is the exception: it is never advertised in the ui/initialize
|
|
109
|
+
// response, so it stays a live getter, which means this object must reach the
|
|
110
|
+
// bridge uncopied and the frame passes its lifecycle work in rather than
|
|
111
|
+
// wrapping the result.
|
|
103
112
|
function buildLiveHandlers(
|
|
104
113
|
initial: McpAppBridgeHandlers | undefined,
|
|
105
114
|
liveRef: { readonly current: LiveSnapshot },
|
|
115
|
+
lifecycle: FrameLifecycle,
|
|
106
116
|
): McpAppBridgeHandlers {
|
|
107
117
|
const live = () => liveRef.current.handlers;
|
|
108
118
|
const has = <K extends keyof McpAppBridgeHandlers>(key: K) =>
|
|
109
119
|
initial?.[key] !== undefined;
|
|
110
120
|
const out: McpAppBridgeHandlers = {};
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
});
|
|
117
|
-
}
|
|
121
|
+
Object.defineProperty(out, "allowedTools", {
|
|
122
|
+
get: () => live()?.allowedTools,
|
|
123
|
+
enumerable: true,
|
|
124
|
+
configurable: true,
|
|
125
|
+
});
|
|
118
126
|
const liveCall = <K extends keyof McpAppBridgeHandlers>(
|
|
119
127
|
key: K,
|
|
120
128
|
): NonNullable<McpAppBridgeHandlers[K]> =>
|
|
@@ -134,8 +142,14 @@ function buildLiveHandlers(
|
|
|
134
142
|
out.updateModelContext = liveCall("updateModelContext");
|
|
135
143
|
if (has("requestDisplayMode"))
|
|
136
144
|
out.requestDisplayMode = liveCall("requestDisplayMode");
|
|
137
|
-
out.onSizeChange = (p) =>
|
|
138
|
-
|
|
145
|
+
out.onSizeChange = (p) => {
|
|
146
|
+
lifecycle.onSizeChange(p);
|
|
147
|
+
live()?.onSizeChange?.(p);
|
|
148
|
+
};
|
|
149
|
+
out.onInitialized = () => {
|
|
150
|
+
lifecycle.onInitialized();
|
|
151
|
+
live()?.onInitialized?.();
|
|
152
|
+
};
|
|
139
153
|
out.onRequestTeardown = (p) => live()?.onRequestTeardown?.(p);
|
|
140
154
|
out.onLog = (p) => live()?.onLog?.(p);
|
|
141
155
|
out.onError = (e) => live()?.onError?.(e);
|
|
@@ -215,23 +229,18 @@ export function McpAppFrame({
|
|
|
215
229
|
}
|
|
216
230
|
};
|
|
217
231
|
|
|
218
|
-
const
|
|
219
|
-
const liveOnInitialized = liveHandlers.onInitialized;
|
|
220
|
-
const wrappedHandlers: McpAppBridgeHandlers = {
|
|
221
|
-
...liveHandlers,
|
|
232
|
+
const wrappedHandlers = buildLiveHandlers(current.handlers, liveRef, {
|
|
222
233
|
onInitialized: () => {
|
|
223
234
|
if (initTimeoutId !== null) {
|
|
224
235
|
clearTimeout(initTimeoutId);
|
|
225
236
|
initTimeoutId = null;
|
|
226
237
|
}
|
|
227
238
|
flushPending();
|
|
228
|
-
liveOnInitialized?.();
|
|
229
239
|
},
|
|
230
240
|
onSizeChange: (p) => {
|
|
231
241
|
if (p.height != null) host.setHeight(p.height);
|
|
232
|
-
liveHandlers.onSizeChange?.(p);
|
|
233
242
|
},
|
|
234
|
-
};
|
|
243
|
+
});
|
|
235
244
|
|
|
236
245
|
// Safety net: if the widget never sends notifications/initialized (broken
|
|
237
246
|
// or non-spec-compliant), flush the queue anyway so the host doesn't
|
|
@@ -37,6 +37,104 @@ async function flush() {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
describe("createMcpAppBridge", () => {
|
|
40
|
+
it("ignores requests and notifications after disposal", async () => {
|
|
41
|
+
const { frame, captured } = makeFrame();
|
|
42
|
+
const callTool = vi.fn().mockResolvedValue({ ok: true });
|
|
43
|
+
const onInitialized = vi.fn();
|
|
44
|
+
const bridge = createMcpAppBridge({
|
|
45
|
+
frame,
|
|
46
|
+
handlers: { callTool, onInitialized },
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
bridge.dispose();
|
|
50
|
+
expect(() => bridge.dispose()).not.toThrow();
|
|
51
|
+
|
|
52
|
+
deliver(bridge, {
|
|
53
|
+
jsonrpc: "2.0",
|
|
54
|
+
id: 1,
|
|
55
|
+
method: "tools/call",
|
|
56
|
+
params: { name: "search" },
|
|
57
|
+
});
|
|
58
|
+
deliver(bridge, {
|
|
59
|
+
jsonrpc: "2.0",
|
|
60
|
+
method: "notifications/initialized",
|
|
61
|
+
});
|
|
62
|
+
await flush();
|
|
63
|
+
|
|
64
|
+
expect(callTool).not.toHaveBeenCalled();
|
|
65
|
+
expect(onInitialized).not.toHaveBeenCalled();
|
|
66
|
+
expect(captured).toEqual([]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("does not send a response after disposal", async () => {
|
|
70
|
+
const { frame, captured } = makeFrame();
|
|
71
|
+
let resolveCall!: (value: unknown) => void;
|
|
72
|
+
const callTool = vi.fn(
|
|
73
|
+
() =>
|
|
74
|
+
new Promise((resolve) => {
|
|
75
|
+
resolveCall = resolve;
|
|
76
|
+
}),
|
|
77
|
+
);
|
|
78
|
+
const bridge = createMcpAppBridge({ frame, handlers: { callTool } });
|
|
79
|
+
|
|
80
|
+
deliver(bridge, {
|
|
81
|
+
jsonrpc: "2.0",
|
|
82
|
+
id: 1,
|
|
83
|
+
method: "tools/call",
|
|
84
|
+
params: { name: "search" },
|
|
85
|
+
});
|
|
86
|
+
expect(callTool).toHaveBeenCalledOnce();
|
|
87
|
+
|
|
88
|
+
bridge.dispose();
|
|
89
|
+
resolveCall({ ok: true });
|
|
90
|
+
await flush();
|
|
91
|
+
|
|
92
|
+
expect(captured).toEqual([]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("does not report an async error after disposal", async () => {
|
|
96
|
+
const { frame, captured } = makeFrame();
|
|
97
|
+
let rejectCall!: (reason?: unknown) => void;
|
|
98
|
+
const callTool = vi.fn(
|
|
99
|
+
() =>
|
|
100
|
+
new Promise((_resolve, reject) => {
|
|
101
|
+
rejectCall = reject;
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
const onError = vi.fn();
|
|
105
|
+
const bridge = createMcpAppBridge({
|
|
106
|
+
frame,
|
|
107
|
+
handlers: { callTool, onError },
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
deliver(bridge, {
|
|
111
|
+
jsonrpc: "2.0",
|
|
112
|
+
id: 1,
|
|
113
|
+
method: "tools/call",
|
|
114
|
+
params: { name: "search" },
|
|
115
|
+
});
|
|
116
|
+
expect(callTool).toHaveBeenCalledOnce();
|
|
117
|
+
|
|
118
|
+
bridge.dispose();
|
|
119
|
+
rejectCall(new Error("tool failed"));
|
|
120
|
+
await flush();
|
|
121
|
+
|
|
122
|
+
expect(onError).not.toHaveBeenCalled();
|
|
123
|
+
expect(captured).toEqual([]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("does not send host notifications after disposal", () => {
|
|
127
|
+
const { frame, captured } = makeFrame();
|
|
128
|
+
const bridge = createMcpAppBridge({ frame });
|
|
129
|
+
|
|
130
|
+
bridge.dispose();
|
|
131
|
+
bridge.notifyToolInput({ query: "hello" });
|
|
132
|
+
bridge.notifyToolResult({ answer: "world" });
|
|
133
|
+
bridge.notifyHostContextChanged({ theme: "dark" });
|
|
134
|
+
|
|
135
|
+
expect(captured).toEqual([]);
|
|
136
|
+
});
|
|
137
|
+
|
|
40
138
|
it("responds to ui/initialize with host info, version, and capabilities", async () => {
|
|
41
139
|
const { frame, captured } = makeFrame();
|
|
42
140
|
const bridge = createMcpAppBridge({
|
package/src/mcp-apps/bridge.ts
CHANGED
|
@@ -90,8 +90,10 @@ export function createMcpAppBridge(
|
|
|
90
90
|
hostInfo = DEFAULT_HOST_INFO,
|
|
91
91
|
hostContext = {},
|
|
92
92
|
} = opts;
|
|
93
|
+
let disposed = false;
|
|
93
94
|
|
|
94
95
|
const post = (msg: McpAppJsonRpcMessage) => {
|
|
96
|
+
if (disposed) return;
|
|
95
97
|
frame.sendMessage(msg);
|
|
96
98
|
};
|
|
97
99
|
|
|
@@ -125,6 +127,7 @@ export function createMcpAppBridge(
|
|
|
125
127
|
};
|
|
126
128
|
|
|
127
129
|
const reportError = (error: Error) => {
|
|
130
|
+
if (disposed) return;
|
|
128
131
|
invokeUserCallback(
|
|
129
132
|
"assistant-ui",
|
|
130
133
|
"MCP App onError",
|
|
@@ -435,6 +438,7 @@ export function createMcpAppBridge(
|
|
|
435
438
|
// The host applies the cross-origin guard before delegating; this only
|
|
436
439
|
// validates the JSON-RPC envelope.
|
|
437
440
|
const onMessage = (event: MessageEvent) => {
|
|
441
|
+
if (disposed) return;
|
|
438
442
|
if (!isJsonRpcMessage(event.data)) return;
|
|
439
443
|
|
|
440
444
|
const msg = event.data;
|
|
@@ -447,7 +451,9 @@ export function createMcpAppBridge(
|
|
|
447
451
|
|
|
448
452
|
return {
|
|
449
453
|
onMessage,
|
|
450
|
-
dispose: () => {
|
|
454
|
+
dispose: () => {
|
|
455
|
+
disposed = true;
|
|
456
|
+
},
|
|
451
457
|
notifyToolInput: (input: unknown) => {
|
|
452
458
|
post({
|
|
453
459
|
jsonrpc: "2.0",
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { renderHook } from "@testing-library/react";
|
|
4
|
+
import { AssistantFrameHost } from "@assistant-ui/core";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { useAssistantFrameHost } from "./useAssistantFrameHost";
|
|
7
|
+
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
vi.restoreAllMocks();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe("useAssistantFrameHost", () => {
|
|
13
|
+
it("unregisters the host when disposal throws", () => {
|
|
14
|
+
const disposalError = new Error("tool cancellation failed");
|
|
15
|
+
const unregistrationError = new Error("unregistration failed");
|
|
16
|
+
vi.spyOn(AssistantFrameHost.prototype, "dispose").mockImplementation(() => {
|
|
17
|
+
throw disposalError;
|
|
18
|
+
});
|
|
19
|
+
const consoleError = vi
|
|
20
|
+
.spyOn(console, "error")
|
|
21
|
+
.mockImplementation(() => undefined);
|
|
22
|
+
const unsubscribe = vi.fn(() => {
|
|
23
|
+
throw unregistrationError;
|
|
24
|
+
});
|
|
25
|
+
const register = vi.fn(() => unsubscribe);
|
|
26
|
+
const iframeRef = {
|
|
27
|
+
current: {
|
|
28
|
+
contentWindow: { postMessage: vi.fn() } as unknown as Window,
|
|
29
|
+
} as HTMLIFrameElement,
|
|
30
|
+
};
|
|
31
|
+
const { unmount } = renderHook(() =>
|
|
32
|
+
useAssistantFrameHost({ iframeRef, register }),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
expect(() => unmount()).toThrow(disposalError);
|
|
36
|
+
expect(unsubscribe).toHaveBeenCalledOnce();
|
|
37
|
+
expect(consoleError).toHaveBeenCalledWith(
|
|
38
|
+
"[assistant-ui] AssistantFrameHost unregistration failed.",
|
|
39
|
+
unregistrationError,
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -41,8 +41,31 @@ export const useAssistantFrameHost = ({
|
|
|
41
41
|
const unsubscribe = register(frameHost);
|
|
42
42
|
|
|
43
43
|
return () => {
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
let cleanupFailed = false;
|
|
45
|
+
let cleanupError: unknown;
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
frameHost.dispose();
|
|
49
|
+
} catch (error) {
|
|
50
|
+
cleanupFailed = true;
|
|
51
|
+
cleanupError = error;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
unsubscribe();
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if (cleanupFailed) {
|
|
58
|
+
console.error(
|
|
59
|
+
"[assistant-ui] AssistantFrameHost unregistration failed.",
|
|
60
|
+
error,
|
|
61
|
+
);
|
|
62
|
+
} else {
|
|
63
|
+
cleanupFailed = true;
|
|
64
|
+
cleanupError = error;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (cleanupFailed) throw cleanupError;
|
|
46
69
|
};
|
|
47
70
|
}, [iframeRef, targetOrigin, register]);
|
|
48
71
|
};
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
act,
|
|
5
|
+
cleanup,
|
|
6
|
+
fireEvent,
|
|
7
|
+
render,
|
|
8
|
+
screen,
|
|
9
|
+
} from "@testing-library/react";
|
|
10
|
+
import { useState } from "react";
|
|
11
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
12
|
+
import { AssistantRuntimeProvider } from "../../context";
|
|
13
|
+
import { useLocalRuntime } from "../../legacy-runtime/runtime-cores/local/useLocalRuntime";
|
|
14
|
+
import { AssistantModalPrimitiveContent } from "./AssistantModalContent";
|
|
15
|
+
import { AssistantModalPrimitiveRoot } from "./AssistantModalRoot";
|
|
16
|
+
import { AssistantModalPrimitiveTrigger } from "./AssistantModalTrigger";
|
|
17
|
+
|
|
18
|
+
const adapter = {
|
|
19
|
+
async *run() {
|
|
20
|
+
yield { content: [{ type: "text" as const, text: "Hello" }] };
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const Modal = ({
|
|
25
|
+
controlled = true,
|
|
26
|
+
onOpenChange,
|
|
27
|
+
...props
|
|
28
|
+
}: AssistantModalPrimitiveRoot.Props & { controlled?: boolean }) => {
|
|
29
|
+
const runtime = useLocalRuntime(adapter);
|
|
30
|
+
const [open, setOpen] = useState(false);
|
|
31
|
+
return (
|
|
32
|
+
<AssistantRuntimeProvider runtime={runtime}>
|
|
33
|
+
<button onClick={() => runtime.thread.append("Hello")}>Start run</button>
|
|
34
|
+
<AssistantModalPrimitiveRoot
|
|
35
|
+
{...(controlled ? { open } : {})}
|
|
36
|
+
onOpenChange={(value) => {
|
|
37
|
+
setOpen(value);
|
|
38
|
+
onOpenChange?.(value);
|
|
39
|
+
}}
|
|
40
|
+
{...props}
|
|
41
|
+
>
|
|
42
|
+
<AssistantModalPrimitiveTrigger>
|
|
43
|
+
Toggle chat
|
|
44
|
+
</AssistantModalPrimitiveTrigger>
|
|
45
|
+
<AssistantModalPrimitiveContent aria-label="Chat">
|
|
46
|
+
Response
|
|
47
|
+
</AssistantModalPrimitiveContent>
|
|
48
|
+
</AssistantModalPrimitiveRoot>
|
|
49
|
+
</AssistantRuntimeProvider>
|
|
50
|
+
);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
afterEach(cleanup);
|
|
54
|
+
|
|
55
|
+
describe("AssistantModalPrimitiveRoot run start", () => {
|
|
56
|
+
it.each([true, false])(
|
|
57
|
+
"opens on run start and closes through the trigger (controlled: %s)",
|
|
58
|
+
async (controlled) => {
|
|
59
|
+
const onOpenChange = vi.fn();
|
|
60
|
+
render(<Modal controlled={controlled} onOpenChange={onOpenChange} />);
|
|
61
|
+
expect(screen.queryByRole("dialog")).toBeNull();
|
|
62
|
+
|
|
63
|
+
await act(async () => {
|
|
64
|
+
fireEvent.click(screen.getByRole("button", { name: "Start run" }));
|
|
65
|
+
});
|
|
66
|
+
expect(await screen.findByRole("dialog", { name: "Chat" })).toBeDefined();
|
|
67
|
+
expect(onOpenChange).toHaveBeenCalledExactlyOnceWith(true);
|
|
68
|
+
|
|
69
|
+
await act(async () => {
|
|
70
|
+
fireEvent.click(screen.getByRole("button", { name: "Start run" }));
|
|
71
|
+
});
|
|
72
|
+
expect(onOpenChange).toHaveBeenCalledExactlyOnceWith(true);
|
|
73
|
+
|
|
74
|
+
fireEvent.click(screen.getByRole("button", { name: "Toggle chat" }));
|
|
75
|
+
expect(screen.queryByRole("dialog")).toBeNull();
|
|
76
|
+
},
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
it.each([{ open: true }, { controlled: false, defaultOpen: true }])(
|
|
80
|
+
"does not request opening when already open (%o)",
|
|
81
|
+
async (props) => {
|
|
82
|
+
const onOpenChange = vi.fn();
|
|
83
|
+
render(<Modal {...props} onOpenChange={onOpenChange} />);
|
|
84
|
+
expect(screen.getByRole("dialog", { name: "Chat" })).toBeDefined();
|
|
85
|
+
|
|
86
|
+
await act(async () => {
|
|
87
|
+
fireEvent.click(screen.getByRole("button", { name: "Start run" }));
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
expect(onOpenChange).not.toHaveBeenCalled();
|
|
91
|
+
expect(screen.getByRole("dialog", { name: "Chat" })).toBeDefined();
|
|
92
|
+
},
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
it("notifies the current owner without overriding its controlled value", async () => {
|
|
96
|
+
const previous = vi.fn();
|
|
97
|
+
const current = vi.fn();
|
|
98
|
+
const view = render(<Modal open={false} onOpenChange={previous} />);
|
|
99
|
+
view.rerender(<Modal open={false} onOpenChange={current} />);
|
|
100
|
+
|
|
101
|
+
await act(async () => {
|
|
102
|
+
fireEvent.click(screen.getByRole("button", { name: "Start run" }));
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
expect(current).toHaveBeenCalledExactlyOnceWith(true);
|
|
106
|
+
expect(previous).not.toHaveBeenCalled();
|
|
107
|
+
expect(screen.queryByRole("dialog")).toBeNull();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("does not request opening when auto-open is disabled", async () => {
|
|
111
|
+
const onOpenChange = vi.fn();
|
|
112
|
+
render(
|
|
113
|
+
<Modal unstable_openOnRunStart={false} onOpenChange={onOpenChange} />,
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
await act(async () => {
|
|
117
|
+
fireEvent.click(screen.getByRole("button", { name: "Start run" }));
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
expect(onOpenChange).not.toHaveBeenCalled();
|
|
121
|
+
expect(screen.queryByRole("dialog")).toBeNull();
|
|
122
|
+
});
|
|
123
|
+
});
|