@assistant-ui/react 0.15.18 → 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 +7 -1
- 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 +2 -2
- package/dist/index.js +2 -2
- package/dist/mcp-apps/app-frame.d.ts.map +1 -1
- package/dist/mcp-apps/app-frame.js +17 -12
- 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/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 +3 -1
- 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 +22 -4
- 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 +14 -6
- 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 +11 -11
- package/src/context/providers/ThreadViewportProvider.tsx +2 -2
- package/src/hooks/useToolCallElapsed.ts +3 -0
- package/src/index.ts +1 -1
- package/src/mcp-apps/app-frame.test.tsx +189 -0
- 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/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
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"getSelectionMessageId.js","names":["QUOTE_SELECTABLE_SELECTOR","getElement","node","Node","HTMLElement","parentElement","findMessageElement","el","id","getAttribute","isExcluded","marker","Element","hasQuoteSelectableRegion","messageElement","matches","querySelectorAll","findQuoteMarker","closest","contains","
|
|
1
|
+
{"version":3,"file":"getSelectionMessageId.js","names":["QUOTE_SELECTABLE_SELECTOR","getElement","node","Node","HTMLElement","parentElement","findMessageElement","el","id","getAttribute","isExcluded","marker","Element","hasQuoteSelectableRegion","messageElement","matches","querySelectorAll","findQuoteMarker","closest","contains","intersectsExcluded","scope","selection","Selection","ranges","Array","from","length","rangeCount","_","i","getRangeAt","some","range","intersectsNode","getSelectionMessageId","anchorNode","focusNode","anchorMessageElement","focusMessageElement","messageId","anchorMarker","focusMarker","commonAncestorContainer"],"sources":["../../src/utils/getSelectionMessageId.ts"],"sourcesContent":["const QUOTE_SELECTABLE_SELECTOR = \"[data-aui-quote-selectable]\";\n\nconst getElement = (node: Node | null): HTMLElement | null => {\n return node instanceof HTMLElement ? node : (node?.parentElement ?? null);\n};\n\nconst findMessageElement = (node: Node | null): HTMLElement | null => {\n let el = node instanceof HTMLElement ? node : (node?.parentElement ?? null);\n while (el) {\n const id = el.getAttribute(\"data-message-id\");\n if (id) return el;\n el = el.parentElement;\n }\n return null;\n};\n\nconst isExcluded = (marker: Element): boolean => {\n return marker.getAttribute(\"data-aui-quote-selectable\") === \"false\";\n};\n\nconst hasQuoteSelectableRegion = (messageElement: HTMLElement) => {\n if (\n messageElement.matches(QUOTE_SELECTABLE_SELECTOR) &&\n !isExcluded(messageElement)\n ) {\n return true;\n }\n for (const marker of messageElement.querySelectorAll(\n QUOTE_SELECTABLE_SELECTOR,\n )) {\n if (!isExcluded(marker)) return true;\n }\n return false;\n};\n\nconst findQuoteMarker = (\n node: Node | null,\n messageElement: HTMLElement,\n): HTMLElement | null => {\n const marker = getElement(node)?.closest(QUOTE_SELECTABLE_SELECTOR);\n if (!(marker instanceof HTMLElement)) return null;\n if (!messageElement.contains(marker)) return null;\n return marker;\n};\n\nconst intersectsExcluded = (scope: Element, selection: Selection): boolean => {\n const ranges = Array.from({ length: selection.rangeCount }, (_, i) =>\n selection.getRangeAt(i),\n );\n for (const marker of scope.querySelectorAll(QUOTE_SELECTABLE_SELECTOR)) {\n if (!isExcluded(marker)) continue;\n if (ranges.some((range) => range.intersectsNode(marker))) return true;\n }\n return false;\n};\n\nexport const getSelectionMessageId = (selection: Selection): string | null => {\n const { anchorNode, focusNode } = selection;\n if (!anchorNode || !focusNode) return null;\n\n const anchorMessageElement = findMessageElement(anchorNode);\n const focusMessageElement = findMessageElement(focusNode);\n\n if (!anchorMessageElement || anchorMessageElement !== focusMessageElement) {\n return null;\n }\n\n const messageId = anchorMessageElement.getAttribute(\"data-message-id\");\n if (!messageId) return null;\n\n const anchorMarker = findQuoteMarker(anchorNode, anchorMessageElement);\n const focusMarker = findQuoteMarker(focusNode, anchorMessageElement);\n\n if (anchorMarker && isExcluded(anchorMarker)) return null;\n if (focusMarker && isExcluded(focusMarker)) return null;\n\n if (hasQuoteSelectableRegion(anchorMessageElement)) {\n if (!anchorMarker || anchorMarker !== focusMarker) return null;\n }\n\n const scope = anchorMarker ?? anchorMessageElement;\n\n for (let i = 0; i < selection.rangeCount; i++) {\n const { commonAncestorContainer } = selection.getRangeAt(i);\n if (!scope.contains(commonAncestorContainer)) return null;\n }\n\n return intersectsExcluded(scope, selection) ? null : messageId;\n};\n"],"mappings":";AAAA,MAAMA,4BAA4B;AAElC,MAAMC,cAAcC,SAA0C;CAC5D,OAAOA,gBAAgBE,cAAcF,OAAQA,MAAMG,iBAAiB;AACtE;AAEA,MAAMC,sBAAsBJ,SAA0C;CACpE,IAAIK,KAAKL,gBAAgBE,cAAcF,OAAQA,MAAMG,iBAAiB;CACtE,OAAOE,IAAI;EAET,IADWA,GAAGE,aAAa,iBACvBD,GAAI,OAAOD;EACfA,KAAKA,GAAGF;CACV;CACA,OAAO;AACT;AAEA,MAAMK,cAAcC,WAA6B;CAC/C,OAAOA,OAAOF,aAAa,2BAA2B,MAAM;AAC9D;AAEA,MAAMI,4BAA4BC,mBAAgC;CAChE,IACEA,eAAeC,QAAQf,yBAAyB,KAChD,CAACU,WAAWI,cAAc,GAE1B,OAAO;CAET,KAAK,MAAMH,UAAUG,eAAeE,iBAClChB,yBACF,GACE,IAAI,CAACU,WAAWC,MAAM,GAAG,OAAO;CAElC,OAAO;AACT;AAEA,MAAMM,mBACJf,MACAY,mBACuB;CACvB,MAAMH,SAASV,WAAWC,IAAI,CAAC,EAAEgB,QAAQlB,yBAAyB;CAClE,IAAI,EAAEW,kBAAkBP,cAAc,OAAO;CAC7C,IAAI,CAACU,eAAeK,SAASR,MAAM,GAAG,OAAO;CAC7C,OAAOA;AACT;AAEA,MAAMS,sBAAsBC,OAAgBC,cAAkC;CAC5E,MAAME,SAASC,MAAMC,KAAK,EAAEC,QAAQL,UAAUM,WAAW,IAAIC,GAAGC,MAC9DR,UAAUS,WAAWD,CAAC,CACxB;CACA,KAAK,MAAMnB,UAAUU,MAAML,iBAAiBhB,yBAAyB,GAAG;EACtE,IAAI,CAACU,WAAWC,MAAM,GAAG;EACzB,IAAIa,OAAOQ,MAAMC,UAAUA,MAAMC,eAAevB,MAAM,CAAC,GAAG,OAAO;CACnE;CACA,OAAO;AACT;AAEA,MAAawB,yBAAyBb,cAAwC;CAC5E,MAAM,EAAEc,YAAYC,cAAcf;CAClC,IAAI,CAACc,cAAc,CAACC,WAAW,OAAO;CAEtC,MAAMC,uBAAuBhC,mBAAmB8B,UAAU;CAC1D,MAAMG,sBAAsBjC,mBAAmB+B,SAAS;CAExD,IAAI,CAACC,wBAAwBA,yBAAyBC,qBACpD,OAAO;CAGT,MAAMC,YAAYF,qBAAqB7B,aAAa,iBAAiB;CACrE,IAAI,CAAC+B,WAAW,OAAO;CAEvB,MAAMC,eAAexB,gBAAgBmB,YAAYE,oBAAoB;CACrE,MAAMI,cAAczB,gBAAgBoB,WAAWC,oBAAoB;CAEnE,IAAIG,gBAAgB/B,WAAW+B,YAAY,GAAG,OAAO;CACrD,IAAIC,eAAehC,WAAWgC,WAAW,GAAG,OAAO;CAEnD,IAAI7B,yBAAyByB,oBAAoB,GAC3C;MAAA,CAACG,gBAAgBA,iBAAiBC,aAAa,OAAO;CAAA;CAG5D,MAAMrB,QAAQoB,gBAAgBH;CAE9B,KAAK,IAAIR,IAAI,GAAGA,IAAIR,UAAUM,YAAYE,KAAK;EAC7C,MAAM,EAAEa,4BAA4BrB,UAAUS,WAAWD,CAAC;EAC1D,IAAI,CAACT,MAAMF,SAASwB,uBAAuB,GAAG,OAAO;CACvD;CAEA,OAAOvB,mBAAmBC,OAAOC,SAAS,IAAI,OAAOkB;AACvD"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@assistant-ui/react",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.19",
|
|
4
4
|
"description": "Open-source TypeScript/React library for building production-grade AI chat experiences",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
@@ -55,14 +55,14 @@
|
|
|
55
55
|
],
|
|
56
56
|
"sideEffects": false,
|
|
57
57
|
"dependencies": {
|
|
58
|
-
"@assistant-ui/core": "^0.3.
|
|
59
|
-
"@assistant-ui/store": "^0.3.
|
|
60
|
-
"@assistant-ui/tap": "^0.9.
|
|
61
|
-
"assistant-cloud": "^0.
|
|
62
|
-
"assistant-stream": "^0.3.
|
|
58
|
+
"@assistant-ui/core": "^0.3.18",
|
|
59
|
+
"@assistant-ui/store": "^0.3.13",
|
|
60
|
+
"@assistant-ui/tap": "^0.9.17",
|
|
61
|
+
"assistant-cloud": "^0.2.0",
|
|
62
|
+
"assistant-stream": "^0.3.42",
|
|
63
63
|
"radix-ui": "^1.6.7",
|
|
64
64
|
"react-textarea-autosize": "^8.5.9",
|
|
65
|
-
"safe-content-frame": "^0.0.
|
|
65
|
+
"safe-content-frame": "^0.0.30",
|
|
66
66
|
"zod": "^4.5.4",
|
|
67
67
|
"zustand": "^5.0.15"
|
|
68
68
|
},
|
|
@@ -81,13 +81,13 @@
|
|
|
81
81
|
}
|
|
82
82
|
},
|
|
83
83
|
"devDependencies": {
|
|
84
|
-
"@assistant-ui/vite": "0.0.
|
|
85
|
-
"@assistant-ui/x-buildutils": "0.0.
|
|
84
|
+
"@assistant-ui/vite": "0.0.17",
|
|
85
|
+
"@assistant-ui/x-buildutils": "0.0.26",
|
|
86
86
|
"@testing-library/react": "^16.3.3",
|
|
87
87
|
"@types/json-schema": "^7.0.15",
|
|
88
|
-
"@types/node": "^26.4.
|
|
88
|
+
"@types/node": "^26.4.1",
|
|
89
89
|
"@types/react": "^19.2.18",
|
|
90
|
-
"@types/react-dom": "^19.2.
|
|
90
|
+
"@types/react-dom": "^19.2.7",
|
|
91
91
|
"jsdom": "^30.0.1",
|
|
92
92
|
"react": "^19.2.8",
|
|
93
93
|
"react-dom": "^19.2.8",
|
|
@@ -25,8 +25,8 @@ const useThreadViewportStoreValue = (options: ThreadViewportStoreOptions) => {
|
|
|
25
25
|
|
|
26
26
|
// Forward scrollToBottom from outer viewport to inner viewport
|
|
27
27
|
useEffect(() => {
|
|
28
|
-
return outerViewport?.getState().onScrollToBottom(() => {
|
|
29
|
-
store.getState().scrollToBottom();
|
|
28
|
+
return outerViewport?.getState().onScrollToBottom((config) => {
|
|
29
|
+
store.getState().scrollToBottom(config);
|
|
30
30
|
});
|
|
31
31
|
}, [outerViewport, store]);
|
|
32
32
|
|
|
@@ -36,6 +36,9 @@ export const useToolCallElapsed = (): number | undefined => {
|
|
|
36
36
|
|
|
37
37
|
useEffect(() => {
|
|
38
38
|
if (!running) return undefined;
|
|
39
|
+
// The clock is an external source; this catches the elapsed value up before
|
|
40
|
+
// the interval takes over.
|
|
41
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
39
42
|
setNow(Date.now());
|
|
40
43
|
const id = setInterval(() => setNow(Date.now()), 1000);
|
|
41
44
|
return () => clearInterval(id);
|
package/src/index.ts
CHANGED
|
@@ -46,7 +46,7 @@ export type {
|
|
|
46
46
|
export { toolApprovalAcceptsText } from "@assistant-ui/core";
|
|
47
47
|
|
|
48
48
|
export { useCloudThreadListRuntime } from "@assistant-ui/core/react";
|
|
49
|
-
export { AssistantCloud } from "assistant-cloud";
|
|
49
|
+
export { AssistantCloud, readAnonymousRefreshToken } from "assistant-cloud";
|
|
50
50
|
|
|
51
51
|
// --- adapters/attachment ---
|
|
52
52
|
export type { AttachmentAdapter } from "@assistant-ui/core";
|
|
@@ -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(() => ({
|
|
@@ -100,6 +101,178 @@ describe("McpAppFrame", () => {
|
|
|
100
101
|
sandboxBridge.dispose();
|
|
101
102
|
});
|
|
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
|
+
|
|
103
276
|
it("only notifies the widget when host context actually changes", () => {
|
|
104
277
|
let createBridge: SandboxHostProps["createBridge"] | null = null;
|
|
105
278
|
sandboxHostMock.mockImplementation((props: SandboxHostProps) => {
|
|
@@ -159,6 +332,22 @@ describe("McpAppFrame", () => {
|
|
|
159
332
|
availableDisplayModes: ["inline", "pip"],
|
|
160
333
|
});
|
|
161
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
|
+
|
|
162
351
|
sandboxBridge.dispose();
|
|
163
352
|
});
|
|
164
353
|
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,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
|
+
});
|