@assistant-ui/react 0.15.17 → 0.15.18
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 +1 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.js +4 -2
- 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 +41 -22
- package/dist/mcp-apps/app-frame.js.map +1 -1
- package/dist/mcp-apps/bridge.js +2 -3
- package/dist/mcp-apps/bridge.js.map +1 -1
- package/dist/mcp-apps/index.d.ts +2 -2
- 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/triggerNavigationResource.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/triggerNavigationResource.js +3 -5
- package/dist/primitives/composer/trigger/triggerNavigationResource.js.map +1 -1
- package/dist/primitives/messagePart/MessagePartText.d.ts +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 +14 -5
- package/dist/sandbox-host/SandboxHost.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/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 +110 -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 +105 -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 +4 -4
- package/package.json +12 -12
- package/src/index.ts +13 -0
- 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 +248 -1
- package/src/mcp-apps/app-frame.tsx +51 -5
- package/src/mcp-apps/bridge.ts +7 -2
- package/src/mcp-apps/index.ts +5 -1
- 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/triggerNavigationResource.ts +3 -10
- package/src/primitives/thread/useThreadViewportAutoScroll.ts +7 -6
- package/src/sandbox-host/SandboxHost.test.tsx +120 -2
- package/src/sandbox-host/SandboxHost.tsx +26 -6
- package/src/unstable/useComposerInputHistory.test.tsx +2 -1
- package/src/unstable/useMentionAdapter.test.tsx +337 -0
- package/src/unstable/useMentionAdapter.ts +94 -57
- 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 +393 -0
- package/src/unstable/webmcp/convertTools.ts +180 -0
- package/src/unstable/webmcp/useWebMcpProvider.test.tsx +466 -0
- package/src/unstable/webmcp/useWebMcpProvider.ts +150 -0
- package/src/unstable/webmcp/webmcp-host.test.ts +142 -0
- package/src/unstable/webmcp/webmcp-host.ts +83 -0
- 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
|
@@ -6,10 +6,13 @@ import {
|
|
|
6
6
|
useMemo,
|
|
7
7
|
useRef,
|
|
8
8
|
useState,
|
|
9
|
-
type MutableRefObject,
|
|
10
9
|
type ReactNode,
|
|
11
10
|
} from "react";
|
|
12
|
-
import type {
|
|
11
|
+
import type {
|
|
12
|
+
McpAppMetadata,
|
|
13
|
+
TextMessagePart,
|
|
14
|
+
ToolCallMessagePart,
|
|
15
|
+
} from "@assistant-ui/core";
|
|
13
16
|
import type {
|
|
14
17
|
ToolCallMessagePartComponent,
|
|
15
18
|
ToolCallMessagePartProps,
|
|
@@ -30,13 +33,11 @@ import type {
|
|
|
30
33
|
import { getMcpAppFromToolPart } from "./utils";
|
|
31
34
|
import { isRecord } from "@assistant-ui/core/internal";
|
|
32
35
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
*/
|
|
39
|
-
host: ResourceElement<McpAppsHost>;
|
|
36
|
+
/**
|
|
37
|
+
* Options that apply to a single MCP app. `McpAppRenderer` takes them as
|
|
38
|
+
* thread-wide defaults, and its `forPart` resolver overrides them for one part.
|
|
39
|
+
*/
|
|
40
|
+
export type McpAppPartOptions = {
|
|
40
41
|
/** Sandbox + container styling. Passes through to SafeContentFrame. */
|
|
41
42
|
sandbox?: McpAppSandboxConfig;
|
|
42
43
|
/**
|
|
@@ -51,6 +52,10 @@ export type McpAppRendererOptions = {
|
|
|
51
52
|
/**
|
|
52
53
|
* Optional widget interaction and lifecycle handlers. Data-plane handlers
|
|
53
54
|
* (`callTool`, `readResource`, `listResources`) always use `host`.
|
|
55
|
+
*
|
|
56
|
+
* Which handlers a widget may call is captured when its frame mounts, so a
|
|
57
|
+
* `forPart` resolver returns the same handler keys for a part on every
|
|
58
|
+
* render even when the implementations change.
|
|
54
59
|
*/
|
|
55
60
|
handlers?: Omit<
|
|
56
61
|
McpAppBridgeHandlers,
|
|
@@ -64,6 +69,26 @@ export type McpAppRendererOptions = {
|
|
|
64
69
|
errorFallback?: ReactNode | ((error: Error) => ReactNode);
|
|
65
70
|
};
|
|
66
71
|
|
|
72
|
+
export type McpAppRendererOptions = McpAppPartOptions & {
|
|
73
|
+
/**
|
|
74
|
+
* Provides the data-plane operations the widget can request
|
|
75
|
+
* (`loadResource`, `callTool`, `readResource`, `listResources`). Use
|
|
76
|
+
* `McpAppsRemoteHost({ url })` for the default HTTP-route convention.
|
|
77
|
+
*/
|
|
78
|
+
host: ResourceElement<McpAppsHost>;
|
|
79
|
+
/**
|
|
80
|
+
* Resolves the options for one part. Each returned value replaces the
|
|
81
|
+
* thread-wide option of the same name, so a host can give every MCP app its
|
|
82
|
+
* own display mode and answer `requestDisplayMode` for the part that asked.
|
|
83
|
+
*
|
|
84
|
+
* `handlers` is the exception and merges per key. Its keys are capabilities
|
|
85
|
+
* negotiated with the widget rather than data, so replacing the whole bag to
|
|
86
|
+
* add one handler would withdraw the others from that part's widget with
|
|
87
|
+
* nothing to observe it by.
|
|
88
|
+
*/
|
|
89
|
+
forPart?: (part: ToolCallMessagePart) => McpAppPartOptions;
|
|
90
|
+
};
|
|
91
|
+
|
|
67
92
|
type LoadedResourceState = {
|
|
68
93
|
host: McpAppsHost;
|
|
69
94
|
resourceUri: string;
|
|
@@ -72,7 +97,31 @@ type LoadedResourceState = {
|
|
|
72
97
|
error?: Error;
|
|
73
98
|
};
|
|
74
99
|
|
|
75
|
-
type
|
|
100
|
+
type RendererState = {
|
|
101
|
+
host: McpAppsHost;
|
|
102
|
+
options: McpAppRendererOptions;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
type UseRendererStore = UseBoundStore<StoreApi<RendererState>>;
|
|
106
|
+
|
|
107
|
+
// Callers pass options as an object literal, so the resource hands this a fresh
|
|
108
|
+
// identity on every run; comparing the values keeps an unchanged run from
|
|
109
|
+
// re-rendering every mounted part. `host` is excluded because it is an element
|
|
110
|
+
// rebuilt on every run, and the store carries the resolved host separately.
|
|
111
|
+
const isSameOptions = (
|
|
112
|
+
a: McpAppRendererOptions,
|
|
113
|
+
b: McpAppRendererOptions,
|
|
114
|
+
): boolean => {
|
|
115
|
+
const aKeys = Object.keys(a).filter((key) => key !== "host");
|
|
116
|
+
const bKeys = Object.keys(b).filter((key) => key !== "host");
|
|
117
|
+
if (aKeys.length !== bKeys.length) return false;
|
|
118
|
+
const aValues = a as Record<string, unknown>;
|
|
119
|
+
const bValues = b as Record<string, unknown>;
|
|
120
|
+
return aKeys.every(
|
|
121
|
+
(key) =>
|
|
122
|
+
Object.hasOwn(bValues, key) && Object.is(aValues[key], bValues[key]),
|
|
123
|
+
);
|
|
124
|
+
};
|
|
76
125
|
|
|
77
126
|
function getInput(part: {
|
|
78
127
|
status: { type: string };
|
|
@@ -92,27 +141,67 @@ const defaultOpenLink = ({ url }: { url: string }) => {
|
|
|
92
141
|
window.open(url, "_blank", "noopener,noreferrer");
|
|
93
142
|
};
|
|
94
143
|
|
|
95
|
-
function
|
|
96
|
-
if (typeof params === "string") return params;
|
|
144
|
+
function extractSendMessageTexts(params: unknown): string[] | undefined {
|
|
145
|
+
if (typeof params === "string") return [params];
|
|
97
146
|
if (!params || typeof params !== "object") return undefined;
|
|
98
147
|
const obj = params as Record<string, unknown>;
|
|
99
|
-
if (typeof obj["prompt"] === "string") return obj["prompt"];
|
|
100
|
-
if (typeof obj["text"] === "string") return obj["text"];
|
|
101
|
-
if (typeof obj["message"] === "string") return obj["message"];
|
|
148
|
+
if (typeof obj["prompt"] === "string") return [obj["prompt"]];
|
|
149
|
+
if (typeof obj["text"] === "string") return [obj["text"]];
|
|
150
|
+
if (typeof obj["message"] === "string") return [obj["message"]];
|
|
151
|
+
if (Array.isArray(obj["content"])) {
|
|
152
|
+
return obj["content"]
|
|
153
|
+
.filter(
|
|
154
|
+
(block): block is { type: "text"; text: string } =>
|
|
155
|
+
isRecord(block) &&
|
|
156
|
+
block["type"] === "text" &&
|
|
157
|
+
typeof block["text"] === "string",
|
|
158
|
+
)
|
|
159
|
+
.map((block) => block.text);
|
|
160
|
+
}
|
|
102
161
|
return undefined;
|
|
103
162
|
}
|
|
104
163
|
|
|
164
|
+
function extractSendMessageContent(
|
|
165
|
+
params: unknown,
|
|
166
|
+
): { content: TextMessagePart[] } | { reason: string } {
|
|
167
|
+
const texts = extractSendMessageTexts(params);
|
|
168
|
+
if (!texts) return { reason: "unrecognised params shape" };
|
|
169
|
+
const content = texts
|
|
170
|
+
.filter((text) => text !== "")
|
|
171
|
+
.map((text): TextMessagePart => ({ type: "text", text }));
|
|
172
|
+
if (content.length === 0) {
|
|
173
|
+
return { reason: "no text content to append" };
|
|
174
|
+
}
|
|
175
|
+
return { content };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function resolvePartOptions(
|
|
179
|
+
options: McpAppRendererOptions,
|
|
180
|
+
part: ToolCallMessagePartProps,
|
|
181
|
+
): McpAppRendererOptions {
|
|
182
|
+
const overrides = options.forPart?.(part);
|
|
183
|
+
if (!overrides) return options;
|
|
184
|
+
const handlers =
|
|
185
|
+
options.handlers && overrides.handlers
|
|
186
|
+
? { ...options.handlers, ...overrides.handlers }
|
|
187
|
+
: (overrides.handlers ?? options.handlers);
|
|
188
|
+
return {
|
|
189
|
+
...options,
|
|
190
|
+
...overrides,
|
|
191
|
+
...(handlers === undefined ? {} : { handlers }),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
105
195
|
function InlineRenderer({
|
|
106
196
|
part,
|
|
107
|
-
|
|
108
|
-
optionsRef,
|
|
197
|
+
useRendererStore,
|
|
109
198
|
}: {
|
|
110
199
|
part: ToolCallMessagePartProps;
|
|
111
|
-
|
|
112
|
-
optionsRef: MutableRefObject<McpAppRendererOptions>;
|
|
200
|
+
useRendererStore: UseRendererStore;
|
|
113
201
|
}) {
|
|
114
|
-
const opts = optionsRef.current;
|
|
115
202
|
const aui = useAui();
|
|
203
|
+
const rendererOptions = useRendererStore((state) => state.options);
|
|
204
|
+
const opts = resolvePartOptions(rendererOptions, part);
|
|
116
205
|
const app = getMcpAppFromToolPart(part);
|
|
117
206
|
const cachedAppRef = useRef<McpAppMetadata | undefined>(undefined);
|
|
118
207
|
useLayoutEffect(() => {
|
|
@@ -122,7 +211,7 @@ function InlineRenderer({
|
|
|
122
211
|
|
|
123
212
|
const [loadedResource, setLoadedResource] = useState<LoadedResourceState>();
|
|
124
213
|
|
|
125
|
-
const host =
|
|
214
|
+
const host = useRendererStore((state) => state.host);
|
|
126
215
|
const resourceUri = appForRender?.resourceUri;
|
|
127
216
|
const serverId = appForRender?.serverId;
|
|
128
217
|
const callerHandlers = opts.handlers;
|
|
@@ -133,12 +222,16 @@ function InlineRenderer({
|
|
|
133
222
|
const targetUri = resourceUri;
|
|
134
223
|
const targetServerId = serverId;
|
|
135
224
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
225
|
+
// Host changes are published later in this passive flush. Defer until that
|
|
226
|
+
// publication lands, then verify this effect still owns the current host.
|
|
227
|
+
const loadResource = async () => {
|
|
228
|
+
await Promise.resolve();
|
|
229
|
+
if (cancelled || useRendererStore.getState().host !== targetHost) return;
|
|
230
|
+
try {
|
|
231
|
+
const res = await targetHost.loadResource({
|
|
232
|
+
uri: targetUri,
|
|
233
|
+
...(targetServerId ? { serverId: targetServerId } : {}),
|
|
234
|
+
});
|
|
142
235
|
if (!cancelled)
|
|
143
236
|
setLoadedResource({
|
|
144
237
|
host: targetHost,
|
|
@@ -148,8 +241,7 @@ function InlineRenderer({
|
|
|
148
241
|
: {}),
|
|
149
242
|
resource: res,
|
|
150
243
|
});
|
|
151
|
-
})
|
|
152
|
-
.catch((error: unknown) => {
|
|
244
|
+
} catch (error: unknown) {
|
|
153
245
|
if (!cancelled) {
|
|
154
246
|
setLoadedResource({
|
|
155
247
|
host: targetHost,
|
|
@@ -160,12 +252,14 @@ function InlineRenderer({
|
|
|
160
252
|
error: error instanceof Error ? error : new Error(String(error)),
|
|
161
253
|
});
|
|
162
254
|
}
|
|
163
|
-
}
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
void loadResource();
|
|
164
258
|
|
|
165
259
|
return () => {
|
|
166
260
|
cancelled = true;
|
|
167
261
|
};
|
|
168
|
-
}, [host, resourceUri, serverId]);
|
|
262
|
+
}, [host, resourceUri, serverId, useRendererStore]);
|
|
169
263
|
|
|
170
264
|
const bridgeHandlers = useMemo<McpAppBridgeHandlers>(
|
|
171
265
|
() => ({
|
|
@@ -174,32 +268,34 @@ function InlineRenderer({
|
|
|
174
268
|
sendMessage:
|
|
175
269
|
callerHandlers?.sendMessage ??
|
|
176
270
|
((params) => {
|
|
177
|
-
const
|
|
178
|
-
if (
|
|
179
|
-
|
|
271
|
+
const result = extractSendMessageContent(params);
|
|
272
|
+
if ("reason" in result) {
|
|
273
|
+
return { isError: true, ok: false, reason: result.reason };
|
|
274
|
+
}
|
|
275
|
+
aui.thread.append({ content: result.content });
|
|
180
276
|
return { ok: true };
|
|
181
277
|
}),
|
|
182
278
|
callTool: (params) =>
|
|
183
|
-
|
|
279
|
+
useRendererStore.getState().host.callTool({
|
|
184
280
|
...params,
|
|
185
281
|
...(serverId ? { serverId } : {}),
|
|
186
282
|
}),
|
|
187
283
|
readResource: (params) =>
|
|
188
|
-
|
|
284
|
+
useRendererStore.getState().host.readResource({
|
|
189
285
|
...params,
|
|
190
286
|
...(serverId ? { serverId } : {}),
|
|
191
287
|
}),
|
|
192
288
|
listResources: (params) => {
|
|
193
289
|
if (!serverId) {
|
|
194
|
-
return
|
|
290
|
+
return useRendererStore.getState().host.listResources(params);
|
|
195
291
|
}
|
|
196
|
-
return
|
|
292
|
+
return useRendererStore.getState().host.listResources({
|
|
197
293
|
...(isRecord(params) ? params : {}),
|
|
198
294
|
serverId,
|
|
199
295
|
});
|
|
200
296
|
},
|
|
201
297
|
}),
|
|
202
|
-
[aui, callerHandlers, serverId,
|
|
298
|
+
[aui, callerHandlers, serverId, useRendererStore],
|
|
203
299
|
);
|
|
204
300
|
|
|
205
301
|
const loadedResourceForApp =
|
|
@@ -253,27 +349,26 @@ const useMcpAppRenderer = (
|
|
|
253
349
|
): { readonly render: ToolCallMessagePartComponent } => {
|
|
254
350
|
const host = useResource(options.host);
|
|
255
351
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
create<{ host: McpAppsHost }>(() => ({ host })),
|
|
352
|
+
// The rendered component identity has to stay stable, or every part remounts
|
|
353
|
+
// its frame; options reach the parts through the store instead of props.
|
|
354
|
+
const [useRendererStore] = useState(() =>
|
|
355
|
+
create<RendererState>(() => ({ host, options })),
|
|
261
356
|
);
|
|
262
357
|
useEffect(() => {
|
|
263
|
-
|
|
264
|
-
|
|
358
|
+
useRendererStore.setState((prev) =>
|
|
359
|
+
prev.host === host && isSameOptions(prev.options, options)
|
|
360
|
+
? prev
|
|
361
|
+
: { host, options },
|
|
362
|
+
);
|
|
363
|
+
}, [host, options, useRendererStore]);
|
|
265
364
|
|
|
266
365
|
const render = useMemo((): ToolCallMessagePartComponent => {
|
|
267
366
|
const Render: ToolCallMessagePartComponent = (props) => (
|
|
268
|
-
<InlineRenderer
|
|
269
|
-
part={props}
|
|
270
|
-
useHostStore={useHostStore}
|
|
271
|
-
optionsRef={optionsRef}
|
|
272
|
-
/>
|
|
367
|
+
<InlineRenderer part={props} useRendererStore={useRendererStore} />
|
|
273
368
|
);
|
|
274
369
|
Render.displayName = "McpAppRenderer";
|
|
275
370
|
return Render;
|
|
276
|
-
}, [
|
|
371
|
+
}, [useRendererStore]);
|
|
277
372
|
|
|
278
373
|
return { render };
|
|
279
374
|
};
|
|
@@ -7,7 +7,7 @@ import type {
|
|
|
7
7
|
SandboxHostProps,
|
|
8
8
|
} from "../sandbox-host/SandboxHost";
|
|
9
9
|
import type { CreateMcpAppBridgeOptions, McpAppBridge } from "./bridge";
|
|
10
|
-
import { MCP_APP_MIME_TYPE } from "./types";
|
|
10
|
+
import { MCP_APP_MIME_TYPE, type McpAppHostContext } from "./types";
|
|
11
11
|
|
|
12
12
|
const { sandboxHostMock, createMcpAppBridgeMock } = vi.hoisted(() => ({
|
|
13
13
|
sandboxHostMock: vi.fn(),
|
|
@@ -99,4 +99,251 @@ describe("McpAppFrame", () => {
|
|
|
99
99
|
|
|
100
100
|
sandboxBridge.dispose();
|
|
101
101
|
});
|
|
102
|
+
|
|
103
|
+
it("only notifies the widget when host context actually changes", () => {
|
|
104
|
+
let createBridge: SandboxHostProps["createBridge"] | null = null;
|
|
105
|
+
sandboxHostMock.mockImplementation((props: SandboxHostProps) => {
|
|
106
|
+
createBridge ??= props.createBridge;
|
|
107
|
+
return null;
|
|
108
|
+
});
|
|
109
|
+
const bridge: McpAppBridge = {
|
|
110
|
+
onMessage: vi.fn(),
|
|
111
|
+
dispose: vi.fn(),
|
|
112
|
+
notifyToolInput: vi.fn(),
|
|
113
|
+
notifyToolResult: vi.fn(),
|
|
114
|
+
notifyHostContextChanged: vi.fn(),
|
|
115
|
+
};
|
|
116
|
+
createMcpAppBridgeMock.mockReturnValue(bridge);
|
|
117
|
+
|
|
118
|
+
const view = (hostContext: McpAppHostContext) => (
|
|
119
|
+
<McpAppFrame
|
|
120
|
+
app={{ resourceUri: "ui://example/widget" }}
|
|
121
|
+
resource={{
|
|
122
|
+
uri: "ui://example/widget",
|
|
123
|
+
mimeType: MCP_APP_MIME_TYPE,
|
|
124
|
+
html: "",
|
|
125
|
+
}}
|
|
126
|
+
hostContext={hostContext}
|
|
127
|
+
/>
|
|
128
|
+
);
|
|
129
|
+
const rendered = render(
|
|
130
|
+
view({ displayMode: "inline", availableDisplayModes: ["inline", "pip"] }),
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const sandboxBridge = createBridge!(
|
|
134
|
+
{
|
|
135
|
+
iframe: document.createElement("iframe"),
|
|
136
|
+
origin: "https://widget.example",
|
|
137
|
+
sendMessage: vi.fn(),
|
|
138
|
+
},
|
|
139
|
+
{ setHeight: vi.fn() },
|
|
140
|
+
);
|
|
141
|
+
const options = createMcpAppBridgeMock.mock
|
|
142
|
+
.calls[0]![0] as CreateMcpAppBridgeOptions;
|
|
143
|
+
options.handlers?.onInitialized?.();
|
|
144
|
+
|
|
145
|
+
rendered.rerender(
|
|
146
|
+
view({ displayMode: "inline", availableDisplayModes: ["inline", "pip"] }),
|
|
147
|
+
);
|
|
148
|
+
expect(bridge.notifyHostContextChanged).not.toHaveBeenCalled();
|
|
149
|
+
|
|
150
|
+
rendered.rerender(
|
|
151
|
+
view({
|
|
152
|
+
displayMode: "fullscreen",
|
|
153
|
+
availableDisplayModes: ["inline", "pip"],
|
|
154
|
+
}),
|
|
155
|
+
);
|
|
156
|
+
expect(bridge.notifyHostContextChanged).toHaveBeenCalledTimes(1);
|
|
157
|
+
expect(bridge.notifyHostContextChanged).toHaveBeenCalledWith({
|
|
158
|
+
displayMode: "fullscreen",
|
|
159
|
+
availableDisplayModes: ["inline", "pip"],
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
sandboxBridge.dispose();
|
|
163
|
+
});
|
|
164
|
+
it("cancels a queued host context that returns to the delivered value", () => {
|
|
165
|
+
let createBridge: SandboxHostProps["createBridge"] | null = null;
|
|
166
|
+
sandboxHostMock.mockImplementation((props: SandboxHostProps) => {
|
|
167
|
+
createBridge ??= props.createBridge;
|
|
168
|
+
return null;
|
|
169
|
+
});
|
|
170
|
+
const bridge: McpAppBridge = {
|
|
171
|
+
onMessage: vi.fn(),
|
|
172
|
+
dispose: vi.fn(),
|
|
173
|
+
notifyToolInput: vi.fn(),
|
|
174
|
+
notifyToolResult: vi.fn(),
|
|
175
|
+
notifyHostContextChanged: vi.fn(),
|
|
176
|
+
};
|
|
177
|
+
createMcpAppBridgeMock.mockReturnValue(bridge);
|
|
178
|
+
|
|
179
|
+
const view = (hostContext: McpAppHostContext) => (
|
|
180
|
+
<McpAppFrame
|
|
181
|
+
app={{ resourceUri: "ui://example/widget" }}
|
|
182
|
+
resource={{
|
|
183
|
+
uri: "ui://example/widget",
|
|
184
|
+
mimeType: MCP_APP_MIME_TYPE,
|
|
185
|
+
html: "",
|
|
186
|
+
}}
|
|
187
|
+
hostContext={hostContext}
|
|
188
|
+
/>
|
|
189
|
+
);
|
|
190
|
+
const rendered = render(view({ displayMode: "inline" }));
|
|
191
|
+
|
|
192
|
+
const sandboxBridge = createBridge!(
|
|
193
|
+
{
|
|
194
|
+
iframe: document.createElement("iframe"),
|
|
195
|
+
origin: "https://widget.example",
|
|
196
|
+
sendMessage: vi.fn(),
|
|
197
|
+
},
|
|
198
|
+
{ setHeight: vi.fn() },
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
rendered.rerender(view({ displayMode: "fullscreen" }));
|
|
202
|
+
rendered.rerender(view({ displayMode: "inline" }));
|
|
203
|
+
|
|
204
|
+
const options = createMcpAppBridgeMock.mock
|
|
205
|
+
.calls[0]![0] as CreateMcpAppBridgeOptions;
|
|
206
|
+
options.handlers?.onInitialized?.();
|
|
207
|
+
|
|
208
|
+
expect(bridge.notifyHostContextChanged).not.toHaveBeenCalled();
|
|
209
|
+
|
|
210
|
+
sandboxBridge.dispose();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("treats an equal host context as unchanged when it is not strict JSON", () => {
|
|
214
|
+
let createBridge: SandboxHostProps["createBridge"] | null = null;
|
|
215
|
+
sandboxHostMock.mockImplementation((props: SandboxHostProps) => {
|
|
216
|
+
createBridge ??= props.createBridge;
|
|
217
|
+
return null;
|
|
218
|
+
});
|
|
219
|
+
const bridge: McpAppBridge = {
|
|
220
|
+
onMessage: vi.fn(),
|
|
221
|
+
dispose: vi.fn(),
|
|
222
|
+
notifyToolInput: vi.fn(),
|
|
223
|
+
notifyToolResult: vi.fn(),
|
|
224
|
+
notifyHostContextChanged: vi.fn(),
|
|
225
|
+
};
|
|
226
|
+
createMcpAppBridgeMock.mockReturnValue(bridge);
|
|
227
|
+
|
|
228
|
+
const view = () => (
|
|
229
|
+
<McpAppFrame
|
|
230
|
+
app={{ resourceUri: "ui://example/widget" }}
|
|
231
|
+
resource={{
|
|
232
|
+
uri: "ui://example/widget",
|
|
233
|
+
mimeType: MCP_APP_MIME_TYPE,
|
|
234
|
+
html: "",
|
|
235
|
+
}}
|
|
236
|
+
hostContext={{ displayMode: "inline", workspace: undefined }}
|
|
237
|
+
/>
|
|
238
|
+
);
|
|
239
|
+
const rendered = render(view());
|
|
240
|
+
|
|
241
|
+
const sandboxBridge = createBridge!(
|
|
242
|
+
{
|
|
243
|
+
iframe: document.createElement("iframe"),
|
|
244
|
+
origin: "https://widget.example",
|
|
245
|
+
sendMessage: vi.fn(),
|
|
246
|
+
},
|
|
247
|
+
{ setHeight: vi.fn() },
|
|
248
|
+
);
|
|
249
|
+
const options = createMcpAppBridgeMock.mock
|
|
250
|
+
.calls[0]![0] as CreateMcpAppBridgeOptions;
|
|
251
|
+
options.handlers?.onInitialized?.();
|
|
252
|
+
|
|
253
|
+
rendered.rerender(view());
|
|
254
|
+
expect(bridge.notifyHostContextChanged).not.toHaveBeenCalled();
|
|
255
|
+
|
|
256
|
+
sandboxBridge.dispose();
|
|
257
|
+
});
|
|
258
|
+
it("notifies when a context value changes behind an unchanged key set", () => {
|
|
259
|
+
let createBridge: SandboxHostProps["createBridge"] | null = null;
|
|
260
|
+
sandboxHostMock.mockImplementation((props: SandboxHostProps) => {
|
|
261
|
+
createBridge ??= props.createBridge;
|
|
262
|
+
return null;
|
|
263
|
+
});
|
|
264
|
+
const bridge: McpAppBridge = {
|
|
265
|
+
onMessage: vi.fn(),
|
|
266
|
+
dispose: vi.fn(),
|
|
267
|
+
notifyToolInput: vi.fn(),
|
|
268
|
+
notifyToolResult: vi.fn(),
|
|
269
|
+
notifyHostContextChanged: vi.fn(),
|
|
270
|
+
};
|
|
271
|
+
createMcpAppBridgeMock.mockReturnValue(bridge);
|
|
272
|
+
|
|
273
|
+
const view = (updatedAt: Date) => (
|
|
274
|
+
<McpAppFrame
|
|
275
|
+
app={{ resourceUri: "ui://example/widget" }}
|
|
276
|
+
resource={{
|
|
277
|
+
uri: "ui://example/widget",
|
|
278
|
+
mimeType: MCP_APP_MIME_TYPE,
|
|
279
|
+
html: "",
|
|
280
|
+
}}
|
|
281
|
+
hostContext={{ updatedAt }}
|
|
282
|
+
/>
|
|
283
|
+
);
|
|
284
|
+
const rendered = render(view(new Date(1)));
|
|
285
|
+
|
|
286
|
+
const sandboxBridge = createBridge!(
|
|
287
|
+
{
|
|
288
|
+
iframe: document.createElement("iframe"),
|
|
289
|
+
origin: "https://widget.example",
|
|
290
|
+
sendMessage: vi.fn(),
|
|
291
|
+
},
|
|
292
|
+
{ setHeight: vi.fn() },
|
|
293
|
+
);
|
|
294
|
+
const options = createMcpAppBridgeMock.mock
|
|
295
|
+
.calls[0]![0] as CreateMcpAppBridgeOptions;
|
|
296
|
+
options.handlers?.onInitialized?.();
|
|
297
|
+
|
|
298
|
+
rendered.rerender(view(new Date(2)));
|
|
299
|
+
expect(bridge.notifyHostContextChanged).toHaveBeenCalledTimes(1);
|
|
300
|
+
|
|
301
|
+
sandboxBridge.dispose();
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("does not repeat the empty context the bridge already delivered", () => {
|
|
305
|
+
let createBridge: SandboxHostProps["createBridge"] | null = null;
|
|
306
|
+
sandboxHostMock.mockImplementation((props: SandboxHostProps) => {
|
|
307
|
+
createBridge ??= props.createBridge;
|
|
308
|
+
return null;
|
|
309
|
+
});
|
|
310
|
+
const bridge: McpAppBridge = {
|
|
311
|
+
onMessage: vi.fn(),
|
|
312
|
+
dispose: vi.fn(),
|
|
313
|
+
notifyToolInput: vi.fn(),
|
|
314
|
+
notifyToolResult: vi.fn(),
|
|
315
|
+
notifyHostContextChanged: vi.fn(),
|
|
316
|
+
};
|
|
317
|
+
createMcpAppBridgeMock.mockReturnValue(bridge);
|
|
318
|
+
|
|
319
|
+
const view = (hostContext?: McpAppHostContext) => (
|
|
320
|
+
<McpAppFrame
|
|
321
|
+
app={{ resourceUri: "ui://example/widget" }}
|
|
322
|
+
resource={{
|
|
323
|
+
uri: "ui://example/widget",
|
|
324
|
+
mimeType: MCP_APP_MIME_TYPE,
|
|
325
|
+
html: "",
|
|
326
|
+
}}
|
|
327
|
+
{...(hostContext === undefined ? {} : { hostContext })}
|
|
328
|
+
/>
|
|
329
|
+
);
|
|
330
|
+
const rendered = render(view());
|
|
331
|
+
|
|
332
|
+
const sandboxBridge = createBridge!(
|
|
333
|
+
{
|
|
334
|
+
iframe: document.createElement("iframe"),
|
|
335
|
+
origin: "https://widget.example",
|
|
336
|
+
sendMessage: vi.fn(),
|
|
337
|
+
},
|
|
338
|
+
{ setHeight: vi.fn() },
|
|
339
|
+
);
|
|
340
|
+
const options = createMcpAppBridgeMock.mock
|
|
341
|
+
.calls[0]![0] as CreateMcpAppBridgeOptions;
|
|
342
|
+
options.handlers?.onInitialized?.();
|
|
343
|
+
|
|
344
|
+
rendered.rerender(view({}));
|
|
345
|
+
expect(bridge.notifyHostContextChanged).not.toHaveBeenCalled();
|
|
346
|
+
|
|
347
|
+
sandboxBridge.dispose();
|
|
348
|
+
});
|
|
102
349
|
});
|
|
@@ -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
|
+
return (
|
|
46
|
+
Array.isArray(a) &&
|
|
47
|
+
Array.isArray(b) &&
|
|
48
|
+
a.length === b.length &&
|
|
49
|
+
a.every((item, index) => isSameHostContext(item, b[index], depth + 1))
|
|
50
|
+
);
|
|
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
|
|
|
@@ -208,8 +251,10 @@ export function McpAppFrame({
|
|
|
208
251
|
|
|
209
252
|
if (current.input !== undefined) pendingInputRef.current = current.input;
|
|
210
253
|
if (current.output !== undefined) pendingOutputRef.current = current.output;
|
|
211
|
-
// hostContext is delivered inside the ui/initialize response
|
|
212
|
-
//
|
|
254
|
+
// hostContext is delivered inside the ui/initialize response, where the
|
|
255
|
+
// bridge defaults it to {}; recording that same normalized value keeps the
|
|
256
|
+
// first later change from repeating what the widget already holds.
|
|
257
|
+
lastSentHostContextRef.current = current.hostContext ?? {};
|
|
213
258
|
|
|
214
259
|
return {
|
|
215
260
|
onMessage: bridge.onMessage,
|
|
@@ -254,6 +299,7 @@ export function McpAppFrame({
|
|
|
254
299
|
pendingHostContextRef,
|
|
255
300
|
lastSentHostContextRef,
|
|
256
301
|
(b, v) => b.notifyHostContextChanged(v),
|
|
302
|
+
isSameHostContext,
|
|
257
303
|
);
|
|
258
304
|
|
|
259
305
|
return (
|