@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.
- package/README.md +8 -2
- package/dist/context/providers/ThreadViewportProvider.js +2 -2
- package/dist/context/providers/ThreadViewportProvider.js.map +1 -1
- package/dist/hooks/useToolCallElapsed.js.map +1 -1
- package/dist/index.d.ts +7 -4
- package/dist/index.js +5 -3
- package/dist/mcp-apps/McpAppRenderer.d.ts +30 -8
- package/dist/mcp-apps/McpAppRenderer.d.ts.map +1 -1
- package/dist/mcp-apps/McpAppRenderer.js +91 -47
- package/dist/mcp-apps/McpAppRenderer.js.map +1 -1
- package/dist/mcp-apps/app-frame.d.ts.map +1 -1
- package/dist/mcp-apps/app-frame.js +57 -33
- package/dist/mcp-apps/app-frame.js.map +1 -1
- package/dist/mcp-apps/bridge.js +9 -4
- package/dist/mcp-apps/bridge.js.map +1 -1
- package/dist/mcp-apps/index.d.ts +2 -2
- 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/ComposerInputPluginContext.d.ts +1 -2
- package/dist/primitives/composer/ComposerInputPluginContext.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerInputPluginContext.js +1 -6
- package/dist/primitives/composer/ComposerInputPluginContext.js.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverAction.js +46 -15
- package/dist/primitives/composer/trigger/TriggerPopoverAction.js.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverDirective.js +40 -10
- package/dist/primitives/composer/trigger/TriggerPopoverDirective.js.map +1 -1
- package/dist/primitives/composer/trigger/matchesTriggerItemQuery.d.ts +6 -0
- package/dist/primitives/composer/trigger/matchesTriggerItemQuery.d.ts.map +1 -0
- package/dist/primitives/composer/trigger/matchesTriggerItemQuery.js +9 -0
- package/dist/primitives/composer/trigger/matchesTriggerItemQuery.js.map +1 -0
- 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 +129 -141
- 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/primitives/thread/useThreadViewportAutoScroll.d.ts.map +1 -1
- package/dist/primitives/thread/useThreadViewportAutoScroll.js +3 -3
- package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
- package/dist/sandbox-host/SandboxHost.d.ts.map +1 -1
- package/dist/sandbox-host/SandboxHost.js +36 -9
- 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.d.ts +9 -3
- package/dist/unstable/useMentionAdapter.d.ts.map +1 -1
- package/dist/unstable/useMentionAdapter.js +50 -43
- 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/useModelContextSnapshot.d.ts +34 -0
- package/dist/unstable/useModelContextSnapshot.d.ts.map +1 -0
- package/dist/unstable/useModelContextSnapshot.js +61 -0
- package/dist/unstable/useModelContextSnapshot.js.map +1 -0
- package/dist/unstable/useSlashCommandAdapter.d.ts.map +1 -1
- package/dist/unstable/useSlashCommandAdapter.js +2 -8
- package/dist/unstable/useSlashCommandAdapter.js.map +1 -1
- package/dist/unstable/webmcp/WebMcpRegistrationResource.d.ts +14 -0
- package/dist/unstable/webmcp/WebMcpRegistrationResource.d.ts.map +1 -0
- package/dist/unstable/webmcp/WebMcpRegistrationResource.js +85 -0
- package/dist/unstable/webmcp/WebMcpRegistrationResource.js.map +1 -0
- package/dist/unstable/webmcp/convertTools.d.ts +21 -0
- package/dist/unstable/webmcp/convertTools.d.ts.map +1 -0
- package/dist/unstable/webmcp/convertTools.js +161 -0
- package/dist/unstable/webmcp/convertTools.js.map +1 -0
- package/dist/unstable/webmcp/useWebMcpProvider.d.ts +21 -0
- package/dist/unstable/webmcp/useWebMcpProvider.d.ts.map +1 -0
- package/dist/unstable/webmcp/useWebMcpProvider.js +106 -0
- package/dist/unstable/webmcp/useWebMcpProvider.js.map +1 -0
- package/dist/unstable/webmcp/webmcp-host.d.ts +39 -0
- package/dist/unstable/webmcp/webmcp-host.d.ts.map +1 -0
- package/dist/unstable/webmcp/webmcp-host.js +29 -0
- package/dist/unstable/webmcp/webmcp-host.js.map +1 -0
- 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 +13 -13
- package/src/context/providers/ThreadViewportProvider.tsx +2 -2
- package/src/hooks/useToolCallElapsed.ts +3 -0
- package/src/index.ts +14 -1
- package/src/mcp-apps/McpAppRenderer.test.tsx +253 -5
- package/src/mcp-apps/McpAppRenderer.tsx +147 -52
- package/src/mcp-apps/app-frame.test.tsx +437 -1
- package/src/mcp-apps/app-frame.tsx +76 -21
- package/src/mcp-apps/bridge.test.ts +98 -0
- package/src/mcp-apps/bridge.ts +14 -3
- package/src/mcp-apps/index.ts +5 -1
- 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/ComposerInputPluginContext.tsx +0 -10
- package/src/primitives/composer/trigger/TriggerPopoverAction.tsx +4 -2
- package/src/primitives/composer/trigger/TriggerPopoverBehavior.test.tsx +109 -0
- package/src/primitives/composer/trigger/TriggerPopoverDirective.tsx +4 -2
- package/src/primitives/composer/trigger/matchesTriggerItemQuery.test.ts +52 -0
- package/src/primitives/composer/trigger/matchesTriggerItemQuery.ts +13 -0
- package/src/primitives/composer/trigger/triggerKeyboardResource.ts +13 -7
- package/src/primitives/composer/trigger/triggerNavigationResource.ts +8 -13
- 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/primitives/thread/useThreadViewportAutoScroll.ts +7 -6
- package/src/sandbox-host/SandboxHost.test.tsx +160 -2
- package/src/sandbox-host/SandboxHost.tsx +50 -10
- package/src/tests/MessageParts.rendererOptions.test.tsx +117 -0
- package/src/tests/toolCallTiming.test.tsx +55 -1
- package/src/unstable/useComposerInputHistory.test.tsx +2 -1
- package/src/unstable/useLiveCompletionAdapter.test.tsx +202 -1
- package/src/unstable/useLiveCompletionAdapter.ts +38 -8
- package/src/unstable/useMentionAdapter.test.tsx +383 -0
- package/src/unstable/useMentionAdapter.ts +94 -57
- package/src/unstable/useMessageStallDetection.ts +26 -19
- package/src/unstable/useModelContextSnapshot.test.tsx +132 -0
- package/src/unstable/useModelContextSnapshot.ts +67 -0
- package/src/unstable/useSlashCommandAdapter.ts +2 -9
- package/src/unstable/webmcp/WebMcpRegistrationResource.ts +92 -0
- package/src/unstable/webmcp/__tests__/webmcp.fake.tsx +168 -0
- package/src/unstable/webmcp/convertTools.test.ts +553 -0
- package/src/unstable/webmcp/convertTools.ts +260 -0
- package/src/unstable/webmcp/useWebMcpProvider.test.tsx +466 -0
- package/src/unstable/webmcp/useWebMcpProvider.ts +148 -0
- package/src/unstable/webmcp/webmcp-host.test.ts +142 -0
- package/src/unstable/webmcp/webmcp-host.ts +83 -0
- 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
- package/dist/utils/invokeCallbackSafely.d.ts +0 -5
- package/dist/utils/invokeCallbackSafely.d.ts.map +0 -1
- package/dist/utils/invokeCallbackSafely.js +0 -15
- package/dist/utils/invokeCallbackSafely.js.map +0 -1
- package/dist/utils/useToolArgsFieldStatus.d.ts +0 -19
- package/dist/utils/useToolArgsFieldStatus.d.ts.map +0 -1
- package/dist/utils/useToolArgsFieldStatus.js +0 -23
- package/dist/utils/useToolArgsFieldStatus.js.map +0 -1
- package/src/utils/invokeCallbackSafely.ts +0 -11
- package/src/utils/useToolArgsFieldStatus.ts +0 -18
|
@@ -7,7 +7,8 @@ import type {
|
|
|
7
7
|
SandboxHostProps,
|
|
8
8
|
} from "../sandbox-host/SandboxHost";
|
|
9
9
|
import type { CreateMcpAppBridgeOptions, McpAppBridge } from "./bridge";
|
|
10
|
-
import
|
|
10
|
+
import type * as BridgeModule from "./bridge";
|
|
11
|
+
import { MCP_APP_MIME_TYPE, type McpAppHostContext } from "./types";
|
|
11
12
|
|
|
12
13
|
const { sandboxHostMock, createMcpAppBridgeMock } = vi.hoisted(() => ({
|
|
13
14
|
sandboxHostMock: vi.fn(),
|
|
@@ -99,4 +100,439 @@ describe("McpAppFrame", () => {
|
|
|
99
100
|
|
|
100
101
|
sandboxBridge.dispose();
|
|
101
102
|
});
|
|
103
|
+
|
|
104
|
+
it.each([
|
|
105
|
+
{ initial: [], next: ["search"], allowed: true },
|
|
106
|
+
{ initial: ["search"], next: [], allowed: false },
|
|
107
|
+
{ initial: undefined, next: ["other"], allowed: false },
|
|
108
|
+
{ initial: [], next: undefined, allowed: true },
|
|
109
|
+
])(
|
|
110
|
+
"applies replacement tool allowlists: $initial -> $next",
|
|
111
|
+
async ({ initial, next, allowed }) => {
|
|
112
|
+
const { createMcpAppBridge } =
|
|
113
|
+
await vi.importActual<typeof BridgeModule>("./bridge");
|
|
114
|
+
createMcpAppBridgeMock.mockImplementationOnce(createMcpAppBridge);
|
|
115
|
+
const captured: { createBridge?: SandboxHostProps["createBridge"] } = {};
|
|
116
|
+
sandboxHostMock.mockImplementation((props: SandboxHostProps) => {
|
|
117
|
+
captured.createBridge ??= props.createBridge;
|
|
118
|
+
return null;
|
|
119
|
+
});
|
|
120
|
+
const callTool = vi.fn(() => ({ content: [] }));
|
|
121
|
+
const view = (allowedTools: readonly string[] | undefined) => (
|
|
122
|
+
<McpAppFrame
|
|
123
|
+
app={{ resourceUri: "ui://example/widget" }}
|
|
124
|
+
resource={{
|
|
125
|
+
uri: "ui://example/widget",
|
|
126
|
+
mimeType: MCP_APP_MIME_TYPE,
|
|
127
|
+
html: "",
|
|
128
|
+
}}
|
|
129
|
+
handlers={
|
|
130
|
+
allowedTools === undefined
|
|
131
|
+
? { callTool }
|
|
132
|
+
: { allowedTools, callTool }
|
|
133
|
+
}
|
|
134
|
+
/>
|
|
135
|
+
);
|
|
136
|
+
const rendered = render(view(initial));
|
|
137
|
+
if (!captured.createBridge) throw new Error("Frame did not mount");
|
|
138
|
+
const sendMessage = vi.fn();
|
|
139
|
+
const bridge = captured.createBridge(
|
|
140
|
+
{
|
|
141
|
+
iframe: document.createElement("iframe"),
|
|
142
|
+
origin: "https://widget.example",
|
|
143
|
+
sendMessage,
|
|
144
|
+
},
|
|
145
|
+
{ setHeight: vi.fn() },
|
|
146
|
+
);
|
|
147
|
+
try {
|
|
148
|
+
rendered.rerender(view(next));
|
|
149
|
+
bridge.onMessage(
|
|
150
|
+
new MessageEvent("message", {
|
|
151
|
+
data: {
|
|
152
|
+
jsonrpc: "2.0",
|
|
153
|
+
id: 1,
|
|
154
|
+
method: "tools/call",
|
|
155
|
+
params: { name: "search" },
|
|
156
|
+
},
|
|
157
|
+
}),
|
|
158
|
+
);
|
|
159
|
+
await Promise.resolve();
|
|
160
|
+
expect(sendMessage).toHaveBeenCalledWith(
|
|
161
|
+
expect.objectContaining(
|
|
162
|
+
allowed
|
|
163
|
+
? { id: 1, result: { content: [] } }
|
|
164
|
+
: { id: 1, error: expect.objectContaining({ code: -32602 }) },
|
|
165
|
+
),
|
|
166
|
+
);
|
|
167
|
+
expect(callTool).toHaveBeenCalledTimes(allowed ? 1 : 0);
|
|
168
|
+
} finally {
|
|
169
|
+
bridge.dispose();
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
it("forwards size changes to the sandbox host and live handler", () => {
|
|
175
|
+
let createBridge: SandboxHostProps["createBridge"] | null = null;
|
|
176
|
+
sandboxHostMock.mockImplementation((props: SandboxHostProps) => {
|
|
177
|
+
createBridge ??= props.createBridge;
|
|
178
|
+
return null;
|
|
179
|
+
});
|
|
180
|
+
const bridge: McpAppBridge = {
|
|
181
|
+
onMessage: vi.fn(),
|
|
182
|
+
dispose: vi.fn(),
|
|
183
|
+
notifyToolInput: vi.fn(),
|
|
184
|
+
notifyToolResult: vi.fn(),
|
|
185
|
+
notifyHostContextChanged: vi.fn(),
|
|
186
|
+
};
|
|
187
|
+
createMcpAppBridgeMock.mockReturnValue(bridge);
|
|
188
|
+
const initialOnSizeChange = vi.fn();
|
|
189
|
+
const replacementOnSizeChange = vi.fn();
|
|
190
|
+
const view = (onSizeChange: typeof initialOnSizeChange) => (
|
|
191
|
+
<McpAppFrame
|
|
192
|
+
app={{ resourceUri: "ui://example/widget" }}
|
|
193
|
+
resource={{
|
|
194
|
+
uri: "ui://example/widget",
|
|
195
|
+
mimeType: MCP_APP_MIME_TYPE,
|
|
196
|
+
html: "",
|
|
197
|
+
}}
|
|
198
|
+
handlers={{ onSizeChange }}
|
|
199
|
+
/>
|
|
200
|
+
);
|
|
201
|
+
const rendered = render(view(initialOnSizeChange));
|
|
202
|
+
|
|
203
|
+
const setHeight = vi.fn();
|
|
204
|
+
const sandboxBridge = createBridge!(
|
|
205
|
+
{
|
|
206
|
+
iframe: document.createElement("iframe"),
|
|
207
|
+
origin: "https://widget.example",
|
|
208
|
+
sendMessage: vi.fn(),
|
|
209
|
+
},
|
|
210
|
+
{ setHeight },
|
|
211
|
+
);
|
|
212
|
+
const options = createMcpAppBridgeMock.mock
|
|
213
|
+
.calls[0]![0] as CreateMcpAppBridgeOptions;
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
const initialSize = { width: 640, height: 360 };
|
|
217
|
+
options.handlers?.onSizeChange?.(initialSize);
|
|
218
|
+
expect(setHeight).toHaveBeenCalledWith(360);
|
|
219
|
+
expect(initialOnSizeChange).toHaveBeenCalledWith(initialSize);
|
|
220
|
+
|
|
221
|
+
rendered.rerender(view(replacementOnSizeChange));
|
|
222
|
+
const replacementSize = { width: 800, height: 480 };
|
|
223
|
+
options.handlers?.onSizeChange?.(replacementSize);
|
|
224
|
+
expect(setHeight).toHaveBeenNthCalledWith(2, 480);
|
|
225
|
+
expect(initialOnSizeChange).toHaveBeenCalledOnce();
|
|
226
|
+
expect(replacementOnSizeChange).toHaveBeenCalledOnce();
|
|
227
|
+
expect(replacementOnSizeChange).toHaveBeenCalledWith(replacementSize);
|
|
228
|
+
|
|
229
|
+
const widthOnlySize = { width: 720 };
|
|
230
|
+
options.handlers?.onSizeChange?.(widthOnlySize);
|
|
231
|
+
expect(setHeight).toHaveBeenCalledTimes(2);
|
|
232
|
+
expect(replacementOnSizeChange).toHaveBeenCalledWith(widthOnlySize);
|
|
233
|
+
} finally {
|
|
234
|
+
sandboxBridge.dispose();
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("disposes the underlying bridge with the sandbox bridge", () => {
|
|
239
|
+
let createBridge: SandboxHostProps["createBridge"] | null = null;
|
|
240
|
+
sandboxHostMock.mockImplementation((props: SandboxHostProps) => {
|
|
241
|
+
createBridge ??= props.createBridge;
|
|
242
|
+
return null;
|
|
243
|
+
});
|
|
244
|
+
const bridge: McpAppBridge = {
|
|
245
|
+
onMessage: vi.fn(),
|
|
246
|
+
dispose: vi.fn(),
|
|
247
|
+
notifyToolInput: vi.fn(),
|
|
248
|
+
notifyToolResult: vi.fn(),
|
|
249
|
+
notifyHostContextChanged: vi.fn(),
|
|
250
|
+
};
|
|
251
|
+
createMcpAppBridgeMock.mockReturnValue(bridge);
|
|
252
|
+
render(
|
|
253
|
+
<McpAppFrame
|
|
254
|
+
app={{ resourceUri: "ui://example/widget" }}
|
|
255
|
+
resource={{
|
|
256
|
+
uri: "ui://example/widget",
|
|
257
|
+
mimeType: MCP_APP_MIME_TYPE,
|
|
258
|
+
html: "",
|
|
259
|
+
}}
|
|
260
|
+
/>,
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
const sandboxBridge = createBridge!(
|
|
264
|
+
{
|
|
265
|
+
iframe: document.createElement("iframe"),
|
|
266
|
+
origin: "https://widget.example",
|
|
267
|
+
sendMessage: vi.fn(),
|
|
268
|
+
},
|
|
269
|
+
{ setHeight: vi.fn() },
|
|
270
|
+
);
|
|
271
|
+
sandboxBridge.dispose();
|
|
272
|
+
|
|
273
|
+
expect(bridge.dispose).toHaveBeenCalledOnce();
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("only notifies the widget when host context actually changes", () => {
|
|
277
|
+
let createBridge: SandboxHostProps["createBridge"] | null = null;
|
|
278
|
+
sandboxHostMock.mockImplementation((props: SandboxHostProps) => {
|
|
279
|
+
createBridge ??= props.createBridge;
|
|
280
|
+
return null;
|
|
281
|
+
});
|
|
282
|
+
const bridge: McpAppBridge = {
|
|
283
|
+
onMessage: vi.fn(),
|
|
284
|
+
dispose: vi.fn(),
|
|
285
|
+
notifyToolInput: vi.fn(),
|
|
286
|
+
notifyToolResult: vi.fn(),
|
|
287
|
+
notifyHostContextChanged: vi.fn(),
|
|
288
|
+
};
|
|
289
|
+
createMcpAppBridgeMock.mockReturnValue(bridge);
|
|
290
|
+
|
|
291
|
+
const view = (hostContext: McpAppHostContext) => (
|
|
292
|
+
<McpAppFrame
|
|
293
|
+
app={{ resourceUri: "ui://example/widget" }}
|
|
294
|
+
resource={{
|
|
295
|
+
uri: "ui://example/widget",
|
|
296
|
+
mimeType: MCP_APP_MIME_TYPE,
|
|
297
|
+
html: "",
|
|
298
|
+
}}
|
|
299
|
+
hostContext={hostContext}
|
|
300
|
+
/>
|
|
301
|
+
);
|
|
302
|
+
const rendered = render(
|
|
303
|
+
view({ displayMode: "inline", availableDisplayModes: ["inline", "pip"] }),
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
const sandboxBridge = createBridge!(
|
|
307
|
+
{
|
|
308
|
+
iframe: document.createElement("iframe"),
|
|
309
|
+
origin: "https://widget.example",
|
|
310
|
+
sendMessage: vi.fn(),
|
|
311
|
+
},
|
|
312
|
+
{ setHeight: vi.fn() },
|
|
313
|
+
);
|
|
314
|
+
const options = createMcpAppBridgeMock.mock
|
|
315
|
+
.calls[0]![0] as CreateMcpAppBridgeOptions;
|
|
316
|
+
options.handlers?.onInitialized?.();
|
|
317
|
+
|
|
318
|
+
rendered.rerender(
|
|
319
|
+
view({ displayMode: "inline", availableDisplayModes: ["inline", "pip"] }),
|
|
320
|
+
);
|
|
321
|
+
expect(bridge.notifyHostContextChanged).not.toHaveBeenCalled();
|
|
322
|
+
|
|
323
|
+
rendered.rerender(
|
|
324
|
+
view({
|
|
325
|
+
displayMode: "fullscreen",
|
|
326
|
+
availableDisplayModes: ["inline", "pip"],
|
|
327
|
+
}),
|
|
328
|
+
);
|
|
329
|
+
expect(bridge.notifyHostContextChanged).toHaveBeenCalledTimes(1);
|
|
330
|
+
expect(bridge.notifyHostContextChanged).toHaveBeenCalledWith({
|
|
331
|
+
displayMode: "fullscreen",
|
|
332
|
+
availableDisplayModes: ["inline", "pip"],
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// A hole is not a value, in either operand order.
|
|
336
|
+
const sparseModes: ("inline" | "pip")[] = new Array(2);
|
|
337
|
+
sparseModes[1] = "pip";
|
|
338
|
+
rendered.rerender(
|
|
339
|
+
view({ displayMode: "fullscreen", availableDisplayModes: sparseModes }),
|
|
340
|
+
);
|
|
341
|
+
expect(bridge.notifyHostContextChanged).toHaveBeenCalledTimes(2);
|
|
342
|
+
|
|
343
|
+
rendered.rerender(
|
|
344
|
+
view({
|
|
345
|
+
displayMode: "fullscreen",
|
|
346
|
+
availableDisplayModes: ["inline", "pip"],
|
|
347
|
+
}),
|
|
348
|
+
);
|
|
349
|
+
expect(bridge.notifyHostContextChanged).toHaveBeenCalledTimes(3);
|
|
350
|
+
|
|
351
|
+
sandboxBridge.dispose();
|
|
352
|
+
});
|
|
353
|
+
it("cancels a queued host context that returns to the delivered value", () => {
|
|
354
|
+
let createBridge: SandboxHostProps["createBridge"] | null = null;
|
|
355
|
+
sandboxHostMock.mockImplementation((props: SandboxHostProps) => {
|
|
356
|
+
createBridge ??= props.createBridge;
|
|
357
|
+
return null;
|
|
358
|
+
});
|
|
359
|
+
const bridge: McpAppBridge = {
|
|
360
|
+
onMessage: vi.fn(),
|
|
361
|
+
dispose: vi.fn(),
|
|
362
|
+
notifyToolInput: vi.fn(),
|
|
363
|
+
notifyToolResult: vi.fn(),
|
|
364
|
+
notifyHostContextChanged: vi.fn(),
|
|
365
|
+
};
|
|
366
|
+
createMcpAppBridgeMock.mockReturnValue(bridge);
|
|
367
|
+
|
|
368
|
+
const view = (hostContext: McpAppHostContext) => (
|
|
369
|
+
<McpAppFrame
|
|
370
|
+
app={{ resourceUri: "ui://example/widget" }}
|
|
371
|
+
resource={{
|
|
372
|
+
uri: "ui://example/widget",
|
|
373
|
+
mimeType: MCP_APP_MIME_TYPE,
|
|
374
|
+
html: "",
|
|
375
|
+
}}
|
|
376
|
+
hostContext={hostContext}
|
|
377
|
+
/>
|
|
378
|
+
);
|
|
379
|
+
const rendered = render(view({ displayMode: "inline" }));
|
|
380
|
+
|
|
381
|
+
const sandboxBridge = createBridge!(
|
|
382
|
+
{
|
|
383
|
+
iframe: document.createElement("iframe"),
|
|
384
|
+
origin: "https://widget.example",
|
|
385
|
+
sendMessage: vi.fn(),
|
|
386
|
+
},
|
|
387
|
+
{ setHeight: vi.fn() },
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
rendered.rerender(view({ displayMode: "fullscreen" }));
|
|
391
|
+
rendered.rerender(view({ displayMode: "inline" }));
|
|
392
|
+
|
|
393
|
+
const options = createMcpAppBridgeMock.mock
|
|
394
|
+
.calls[0]![0] as CreateMcpAppBridgeOptions;
|
|
395
|
+
options.handlers?.onInitialized?.();
|
|
396
|
+
|
|
397
|
+
expect(bridge.notifyHostContextChanged).not.toHaveBeenCalled();
|
|
398
|
+
|
|
399
|
+
sandboxBridge.dispose();
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it("treats an equal host context as unchanged when it is not strict JSON", () => {
|
|
403
|
+
let createBridge: SandboxHostProps["createBridge"] | null = null;
|
|
404
|
+
sandboxHostMock.mockImplementation((props: SandboxHostProps) => {
|
|
405
|
+
createBridge ??= props.createBridge;
|
|
406
|
+
return null;
|
|
407
|
+
});
|
|
408
|
+
const bridge: McpAppBridge = {
|
|
409
|
+
onMessage: vi.fn(),
|
|
410
|
+
dispose: vi.fn(),
|
|
411
|
+
notifyToolInput: vi.fn(),
|
|
412
|
+
notifyToolResult: vi.fn(),
|
|
413
|
+
notifyHostContextChanged: vi.fn(),
|
|
414
|
+
};
|
|
415
|
+
createMcpAppBridgeMock.mockReturnValue(bridge);
|
|
416
|
+
|
|
417
|
+
const view = () => (
|
|
418
|
+
<McpAppFrame
|
|
419
|
+
app={{ resourceUri: "ui://example/widget" }}
|
|
420
|
+
resource={{
|
|
421
|
+
uri: "ui://example/widget",
|
|
422
|
+
mimeType: MCP_APP_MIME_TYPE,
|
|
423
|
+
html: "",
|
|
424
|
+
}}
|
|
425
|
+
hostContext={{ displayMode: "inline", workspace: undefined }}
|
|
426
|
+
/>
|
|
427
|
+
);
|
|
428
|
+
const rendered = render(view());
|
|
429
|
+
|
|
430
|
+
const sandboxBridge = createBridge!(
|
|
431
|
+
{
|
|
432
|
+
iframe: document.createElement("iframe"),
|
|
433
|
+
origin: "https://widget.example",
|
|
434
|
+
sendMessage: vi.fn(),
|
|
435
|
+
},
|
|
436
|
+
{ setHeight: vi.fn() },
|
|
437
|
+
);
|
|
438
|
+
const options = createMcpAppBridgeMock.mock
|
|
439
|
+
.calls[0]![0] as CreateMcpAppBridgeOptions;
|
|
440
|
+
options.handlers?.onInitialized?.();
|
|
441
|
+
|
|
442
|
+
rendered.rerender(view());
|
|
443
|
+
expect(bridge.notifyHostContextChanged).not.toHaveBeenCalled();
|
|
444
|
+
|
|
445
|
+
sandboxBridge.dispose();
|
|
446
|
+
});
|
|
447
|
+
it("notifies when a context value changes behind an unchanged key set", () => {
|
|
448
|
+
let createBridge: SandboxHostProps["createBridge"] | null = null;
|
|
449
|
+
sandboxHostMock.mockImplementation((props: SandboxHostProps) => {
|
|
450
|
+
createBridge ??= props.createBridge;
|
|
451
|
+
return null;
|
|
452
|
+
});
|
|
453
|
+
const bridge: McpAppBridge = {
|
|
454
|
+
onMessage: vi.fn(),
|
|
455
|
+
dispose: vi.fn(),
|
|
456
|
+
notifyToolInput: vi.fn(),
|
|
457
|
+
notifyToolResult: vi.fn(),
|
|
458
|
+
notifyHostContextChanged: vi.fn(),
|
|
459
|
+
};
|
|
460
|
+
createMcpAppBridgeMock.mockReturnValue(bridge);
|
|
461
|
+
|
|
462
|
+
const view = (updatedAt: Date) => (
|
|
463
|
+
<McpAppFrame
|
|
464
|
+
app={{ resourceUri: "ui://example/widget" }}
|
|
465
|
+
resource={{
|
|
466
|
+
uri: "ui://example/widget",
|
|
467
|
+
mimeType: MCP_APP_MIME_TYPE,
|
|
468
|
+
html: "",
|
|
469
|
+
}}
|
|
470
|
+
hostContext={{ updatedAt }}
|
|
471
|
+
/>
|
|
472
|
+
);
|
|
473
|
+
const rendered = render(view(new Date(1)));
|
|
474
|
+
|
|
475
|
+
const sandboxBridge = createBridge!(
|
|
476
|
+
{
|
|
477
|
+
iframe: document.createElement("iframe"),
|
|
478
|
+
origin: "https://widget.example",
|
|
479
|
+
sendMessage: vi.fn(),
|
|
480
|
+
},
|
|
481
|
+
{ setHeight: vi.fn() },
|
|
482
|
+
);
|
|
483
|
+
const options = createMcpAppBridgeMock.mock
|
|
484
|
+
.calls[0]![0] as CreateMcpAppBridgeOptions;
|
|
485
|
+
options.handlers?.onInitialized?.();
|
|
486
|
+
|
|
487
|
+
rendered.rerender(view(new Date(2)));
|
|
488
|
+
expect(bridge.notifyHostContextChanged).toHaveBeenCalledTimes(1);
|
|
489
|
+
|
|
490
|
+
sandboxBridge.dispose();
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it("does not repeat the empty context the bridge already delivered", () => {
|
|
494
|
+
let createBridge: SandboxHostProps["createBridge"] | null = null;
|
|
495
|
+
sandboxHostMock.mockImplementation((props: SandboxHostProps) => {
|
|
496
|
+
createBridge ??= props.createBridge;
|
|
497
|
+
return null;
|
|
498
|
+
});
|
|
499
|
+
const bridge: McpAppBridge = {
|
|
500
|
+
onMessage: vi.fn(),
|
|
501
|
+
dispose: vi.fn(),
|
|
502
|
+
notifyToolInput: vi.fn(),
|
|
503
|
+
notifyToolResult: vi.fn(),
|
|
504
|
+
notifyHostContextChanged: vi.fn(),
|
|
505
|
+
};
|
|
506
|
+
createMcpAppBridgeMock.mockReturnValue(bridge);
|
|
507
|
+
|
|
508
|
+
const view = (hostContext?: McpAppHostContext) => (
|
|
509
|
+
<McpAppFrame
|
|
510
|
+
app={{ resourceUri: "ui://example/widget" }}
|
|
511
|
+
resource={{
|
|
512
|
+
uri: "ui://example/widget",
|
|
513
|
+
mimeType: MCP_APP_MIME_TYPE,
|
|
514
|
+
html: "",
|
|
515
|
+
}}
|
|
516
|
+
{...(hostContext === undefined ? {} : { hostContext })}
|
|
517
|
+
/>
|
|
518
|
+
);
|
|
519
|
+
const rendered = render(view());
|
|
520
|
+
|
|
521
|
+
const sandboxBridge = createBridge!(
|
|
522
|
+
{
|
|
523
|
+
iframe: document.createElement("iframe"),
|
|
524
|
+
origin: "https://widget.example",
|
|
525
|
+
sendMessage: vi.fn(),
|
|
526
|
+
},
|
|
527
|
+
{ setHeight: vi.fn() },
|
|
528
|
+
);
|
|
529
|
+
const options = createMcpAppBridgeMock.mock
|
|
530
|
+
.calls[0]![0] as CreateMcpAppBridgeOptions;
|
|
531
|
+
options.handlers?.onInitialized?.();
|
|
532
|
+
|
|
533
|
+
rendered.rerender(view({}));
|
|
534
|
+
expect(bridge.notifyHostContextChanged).not.toHaveBeenCalled();
|
|
535
|
+
|
|
536
|
+
sandboxBridge.dispose();
|
|
537
|
+
});
|
|
102
538
|
});
|
|
@@ -18,11 +18,48 @@ import type {
|
|
|
18
18
|
McpAppFrameProps,
|
|
19
19
|
McpAppHostContext,
|
|
20
20
|
} from "./types";
|
|
21
|
+
import { isRecord } from "@assistant-ui/core/internal";
|
|
21
22
|
|
|
22
23
|
const DEFAULT_PRODUCT = "assistant-ui-mcp-app";
|
|
23
24
|
const INIT_TIMEOUT_MS = 5000;
|
|
24
25
|
const DEFAULT_MAX_HEIGHT = 800;
|
|
25
26
|
|
|
27
|
+
// Only a plain object can be compared by its own keys. Structured clone carries
|
|
28
|
+
// a Date, Map, or class instance to the widget intact, and those expose no
|
|
29
|
+
// enumerable keys, so walking them would report two different values as equal.
|
|
30
|
+
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
|
|
31
|
+
if (!isRecord(value)) return false;
|
|
32
|
+
const prototype = Object.getPrototypeOf(value);
|
|
33
|
+
return prototype === null || prototype === Object.prototype;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// Hosts commonly rebuild an equal context object on every render, and the
|
|
37
|
+
// widget cannot tell a repeated notification apart from a real change. The
|
|
38
|
+
// open index signature on McpAppHostContext admits values JSON rejects (an
|
|
39
|
+
// undefined property value is enough), so leaves the walk cannot compare fall
|
|
40
|
+
// back to identity rather than failing the whole comparison.
|
|
41
|
+
const isSameHostContext = (a: unknown, b: unknown, depth = 0): boolean => {
|
|
42
|
+
if (Object.is(a, b)) return true;
|
|
43
|
+
if (depth > 100) return false;
|
|
44
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
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
|
+
}
|
|
52
|
+
if (!isPlainObject(a) || !isPlainObject(b)) return false;
|
|
53
|
+
const aKeys = Object.keys(a);
|
|
54
|
+
return (
|
|
55
|
+
aKeys.length === Object.keys(b).length &&
|
|
56
|
+
aKeys.every(
|
|
57
|
+
(key) =>
|
|
58
|
+
Object.hasOwn(b, key) && isSameHostContext(a[key], b[key], depth + 1),
|
|
59
|
+
)
|
|
60
|
+
);
|
|
61
|
+
};
|
|
62
|
+
|
|
26
63
|
function useBridgeNotify<T>(
|
|
27
64
|
value: T | undefined,
|
|
28
65
|
bridgeRef: MutableRefObject<McpAppBridge | null>,
|
|
@@ -30,18 +67,24 @@ function useBridgeNotify<T>(
|
|
|
30
67
|
pendingRef: MutableRefObject<T | undefined>,
|
|
31
68
|
lastSentRef: MutableRefObject<T | undefined>,
|
|
32
69
|
notify: (bridge: McpAppBridge, v: T) => void,
|
|
70
|
+
isEqual: (sent: T | undefined, next: T) => boolean = Object.is,
|
|
33
71
|
) {
|
|
34
72
|
useEffect(() => {
|
|
35
73
|
if (!bridgeRef.current) return;
|
|
36
|
-
|
|
37
|
-
|
|
74
|
+
// Nothing to deliver, either because there is no value or because the
|
|
75
|
+
// widget already holds it; a queued value here is superseded and would
|
|
76
|
+
// otherwise flush after initialization as a stale update.
|
|
77
|
+
if (value === undefined || isEqual(lastSentRef.current, value)) {
|
|
78
|
+
pendingRef.current = undefined;
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
38
81
|
if (!widgetReadyRef.current) {
|
|
39
82
|
pendingRef.current = value;
|
|
40
83
|
return;
|
|
41
84
|
}
|
|
42
85
|
notify(bridgeRef.current, value);
|
|
43
86
|
lastSentRef.current = value;
|
|
44
|
-
// oxlint-disable-next-line react/exhaustive-deps -- refs are stable; notify
|
|
87
|
+
// oxlint-disable-next-line react/exhaustive-deps -- refs are stable; notify and isEqual are assumed stable; re-run only when value changes
|
|
45
88
|
}, [value]);
|
|
46
89
|
}
|
|
47
90
|
|
|
@@ -53,25 +96,33 @@ type LiveSnapshot = {
|
|
|
53
96
|
output: unknown;
|
|
54
97
|
};
|
|
55
98
|
|
|
99
|
+
type FrameLifecycle = {
|
|
100
|
+
onInitialized: () => void;
|
|
101
|
+
onSizeChange: (params: { width?: number; height?: number }) => void;
|
|
102
|
+
};
|
|
103
|
+
|
|
56
104
|
// Proxy each per-call handler through liveRef so the bridge always dispatches
|
|
57
105
|
// to the latest handler reference (e.g. inline callbacks closing over state).
|
|
58
106
|
// Capability presence is snapshot at mount: a handler added later requires a
|
|
59
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.
|
|
60
112
|
function buildLiveHandlers(
|
|
61
113
|
initial: McpAppBridgeHandlers | undefined,
|
|
62
114
|
liveRef: { readonly current: LiveSnapshot },
|
|
115
|
+
lifecycle: FrameLifecycle,
|
|
63
116
|
): McpAppBridgeHandlers {
|
|
64
117
|
const live = () => liveRef.current.handlers;
|
|
65
118
|
const has = <K extends keyof McpAppBridgeHandlers>(key: K) =>
|
|
66
119
|
initial?.[key] !== undefined;
|
|
67
120
|
const out: McpAppBridgeHandlers = {};
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
});
|
|
74
|
-
}
|
|
121
|
+
Object.defineProperty(out, "allowedTools", {
|
|
122
|
+
get: () => live()?.allowedTools,
|
|
123
|
+
enumerable: true,
|
|
124
|
+
configurable: true,
|
|
125
|
+
});
|
|
75
126
|
const liveCall = <K extends keyof McpAppBridgeHandlers>(
|
|
76
127
|
key: K,
|
|
77
128
|
): NonNullable<McpAppBridgeHandlers[K]> =>
|
|
@@ -91,8 +142,14 @@ function buildLiveHandlers(
|
|
|
91
142
|
out.updateModelContext = liveCall("updateModelContext");
|
|
92
143
|
if (has("requestDisplayMode"))
|
|
93
144
|
out.requestDisplayMode = liveCall("requestDisplayMode");
|
|
94
|
-
out.onSizeChange = (p) =>
|
|
95
|
-
|
|
145
|
+
out.onSizeChange = (p) => {
|
|
146
|
+
lifecycle.onSizeChange(p);
|
|
147
|
+
live()?.onSizeChange?.(p);
|
|
148
|
+
};
|
|
149
|
+
out.onInitialized = () => {
|
|
150
|
+
lifecycle.onInitialized();
|
|
151
|
+
live()?.onInitialized?.();
|
|
152
|
+
};
|
|
96
153
|
out.onRequestTeardown = (p) => live()?.onRequestTeardown?.(p);
|
|
97
154
|
out.onLog = (p) => live()?.onLog?.(p);
|
|
98
155
|
out.onError = (e) => live()?.onError?.(e);
|
|
@@ -172,23 +229,18 @@ export function McpAppFrame({
|
|
|
172
229
|
}
|
|
173
230
|
};
|
|
174
231
|
|
|
175
|
-
const
|
|
176
|
-
const liveOnInitialized = liveHandlers.onInitialized;
|
|
177
|
-
const wrappedHandlers: McpAppBridgeHandlers = {
|
|
178
|
-
...liveHandlers,
|
|
232
|
+
const wrappedHandlers = buildLiveHandlers(current.handlers, liveRef, {
|
|
179
233
|
onInitialized: () => {
|
|
180
234
|
if (initTimeoutId !== null) {
|
|
181
235
|
clearTimeout(initTimeoutId);
|
|
182
236
|
initTimeoutId = null;
|
|
183
237
|
}
|
|
184
238
|
flushPending();
|
|
185
|
-
liveOnInitialized?.();
|
|
186
239
|
},
|
|
187
240
|
onSizeChange: (p) => {
|
|
188
241
|
if (p.height != null) host.setHeight(p.height);
|
|
189
|
-
liveHandlers.onSizeChange?.(p);
|
|
190
242
|
},
|
|
191
|
-
};
|
|
243
|
+
});
|
|
192
244
|
|
|
193
245
|
// Safety net: if the widget never sends notifications/initialized (broken
|
|
194
246
|
// or non-spec-compliant), flush the queue anyway so the host doesn't
|
|
@@ -208,8 +260,10 @@ export function McpAppFrame({
|
|
|
208
260
|
|
|
209
261
|
if (current.input !== undefined) pendingInputRef.current = current.input;
|
|
210
262
|
if (current.output !== undefined) pendingOutputRef.current = current.output;
|
|
211
|
-
// hostContext is delivered inside the ui/initialize response
|
|
212
|
-
//
|
|
263
|
+
// hostContext is delivered inside the ui/initialize response, where the
|
|
264
|
+
// bridge defaults it to {}; recording that same normalized value keeps the
|
|
265
|
+
// first later change from repeating what the widget already holds.
|
|
266
|
+
lastSentHostContextRef.current = current.hostContext ?? {};
|
|
213
267
|
|
|
214
268
|
return {
|
|
215
269
|
onMessage: bridge.onMessage,
|
|
@@ -254,6 +308,7 @@ export function McpAppFrame({
|
|
|
254
308
|
pendingHostContextRef,
|
|
255
309
|
lastSentHostContextRef,
|
|
256
310
|
(b, v) => b.notifyHostContextChanged(v),
|
|
311
|
+
isSameHostContext,
|
|
257
312
|
);
|
|
258
313
|
|
|
259
314
|
return (
|