@assistant-ui/react 0.15.26 → 0.15.28
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/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/mcp-apps/app-frame.js +2 -2
- package/dist/mcp-apps/app-frame.js.map +1 -1
- package/dist/mcp-apps/bridge.d.ts.map +1 -1
- package/dist/mcp-apps/bridge.js +28 -3
- package/dist/mcp-apps/bridge.js.map +1 -1
- package/dist/mcp-apps/index.d.ts +1 -1
- package/dist/mcp-apps/index.d.ts.map +1 -1
- package/dist/mcp-apps/types.d.ts +18 -1
- package/dist/mcp-apps/types.d.ts.map +1 -1
- package/dist/mcp-apps/types.js.map +1 -1
- package/dist/primitives/composer/ComposerAttachmentDropzone.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerAttachmentDropzone.js +14 -12
- package/dist/primitives/composer/ComposerAttachmentDropzone.js.map +1 -1
- package/dist/primitives/composer/ComposerRoot.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerRoot.js +63 -55
- package/dist/primitives/composer/ComposerRoot.js.map +1 -1
- package/dist/unstable/useComposerInput.d.ts +2 -0
- package/dist/unstable/useComposerInput.d.ts.map +1 -1
- package/dist/unstable/useComposerInput.js +2 -0
- package/dist/unstable/useComposerInput.js.map +1 -1
- package/package.json +7 -7
- package/src/index.ts +1 -0
- package/src/mcp-apps/McpAppRenderer.test.tsx +7 -2
- package/src/mcp-apps/app-frame.test.tsx +64 -6
- package/src/mcp-apps/app-frame.tsx +2 -2
- package/src/mcp-apps/bridge.test.ts +154 -0
- package/src/mcp-apps/bridge.ts +51 -9
- package/src/mcp-apps/index.ts +1 -0
- package/src/mcp-apps/types.ts +15 -1
- package/src/primitives/composer/ComposerAttachmentDropzone.test.tsx +32 -1
- package/src/primitives/composer/ComposerAttachmentDropzone.tsx +14 -12
- package/src/primitives/composer/ComposerDisabledThread.test.tsx +121 -0
- package/src/primitives/composer/ComposerRoot.test.tsx +21 -1
- package/src/primitives/composer/ComposerRoot.tsx +8 -1
- package/src/unstable/useComposerInput.ts +2 -0
|
@@ -197,6 +197,160 @@ describe("createMcpAppBridge", () => {
|
|
|
197
197
|
bridge.dispose();
|
|
198
198
|
});
|
|
199
199
|
|
|
200
|
+
it("passes view initialization metadata to onInitialized", () => {
|
|
201
|
+
const { frame } = makeFrame();
|
|
202
|
+
const onInitialized = vi.fn();
|
|
203
|
+
const bridge = createMcpAppBridge({ frame, handlers: { onInitialized } });
|
|
204
|
+
const params = {
|
|
205
|
+
protocolVersion: "2026-01-26",
|
|
206
|
+
appInfo: { name: "example-app", version: "1.2.3" },
|
|
207
|
+
appCapabilities: {
|
|
208
|
+
availableDisplayModes: ["inline", "fullscreen"],
|
|
209
|
+
tools: { listChanged: true },
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
deliver(bridge, {
|
|
214
|
+
jsonrpc: "2.0",
|
|
215
|
+
id: 1,
|
|
216
|
+
method: "ui/initialize",
|
|
217
|
+
params,
|
|
218
|
+
});
|
|
219
|
+
expect(onInitialized).not.toHaveBeenCalled();
|
|
220
|
+
deliver(bridge, {
|
|
221
|
+
jsonrpc: "2.0",
|
|
222
|
+
method: "notifications/initialized",
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
expect(onInitialized).toHaveBeenCalledOnce();
|
|
226
|
+
expect(onInitialized).toHaveBeenCalledWith(params);
|
|
227
|
+
bridge.dispose();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("omits malformed initialization fields and keeps the protocol fallback", async () => {
|
|
231
|
+
const { frame, captured } = makeFrame();
|
|
232
|
+
const onInitialized = vi.fn();
|
|
233
|
+
const bridge = createMcpAppBridge({ frame, handlers: { onInitialized } });
|
|
234
|
+
|
|
235
|
+
deliver(bridge, {
|
|
236
|
+
jsonrpc: "2.0",
|
|
237
|
+
id: 1,
|
|
238
|
+
method: "ui/initialize",
|
|
239
|
+
params: {
|
|
240
|
+
protocolVersion: 1,
|
|
241
|
+
appInfo: { name: "malformed" },
|
|
242
|
+
appCapabilities: { availableDisplayModes: "fullscreen" },
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
deliver(bridge, {
|
|
246
|
+
jsonrpc: "2.0",
|
|
247
|
+
method: "notifications/initialized",
|
|
248
|
+
});
|
|
249
|
+
await flush();
|
|
250
|
+
|
|
251
|
+
expect(onInitialized).toHaveBeenCalledOnce();
|
|
252
|
+
expect(onInitialized).toHaveBeenCalledWith({
|
|
253
|
+
appCapabilities: {},
|
|
254
|
+
});
|
|
255
|
+
expect((captured[0] as McpAppJsonRpcResponse).result).toMatchObject({
|
|
256
|
+
protocolVersion: MCP_APP_PROTOCOL_VERSION,
|
|
257
|
+
});
|
|
258
|
+
bridge.dispose();
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("keeps only the display modes the host recognizes", () => {
|
|
262
|
+
const { frame } = makeFrame();
|
|
263
|
+
const onInitialized = vi.fn();
|
|
264
|
+
const bridge = createMcpAppBridge({ frame, handlers: { onInitialized } });
|
|
265
|
+
|
|
266
|
+
deliver(bridge, {
|
|
267
|
+
jsonrpc: "2.0",
|
|
268
|
+
id: 1,
|
|
269
|
+
method: "ui/initialize",
|
|
270
|
+
params: {
|
|
271
|
+
appCapabilities: { availableDisplayModes: ["inline", "sidebar"] },
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
deliver(bridge, {
|
|
275
|
+
jsonrpc: "2.0",
|
|
276
|
+
method: "notifications/initialized",
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
expect(onInitialized).toHaveBeenCalledWith({
|
|
280
|
+
appCapabilities: { availableDisplayModes: ["inline"] },
|
|
281
|
+
});
|
|
282
|
+
bridge.dispose();
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("reads a sparse display mode list without walking its length", () => {
|
|
286
|
+
const { frame } = makeFrame();
|
|
287
|
+
const onInitialized = vi.fn();
|
|
288
|
+
const bridge = createMcpAppBridge({ frame, handlers: { onInitialized } });
|
|
289
|
+
const availableDisplayModes = ["inline"];
|
|
290
|
+
availableDisplayModes.length = 2 ** 32 - 1;
|
|
291
|
+
|
|
292
|
+
deliver(bridge, {
|
|
293
|
+
jsonrpc: "2.0",
|
|
294
|
+
id: 1,
|
|
295
|
+
method: "ui/initialize",
|
|
296
|
+
params: { appCapabilities: { availableDisplayModes } },
|
|
297
|
+
});
|
|
298
|
+
deliver(bridge, {
|
|
299
|
+
jsonrpc: "2.0",
|
|
300
|
+
method: "notifications/initialized",
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
expect(onInitialized).toHaveBeenCalledWith({
|
|
304
|
+
appCapabilities: { availableDisplayModes: ["inline"] },
|
|
305
|
+
});
|
|
306
|
+
bridge.dispose();
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("initializes when the display mode list carries its own methods", async () => {
|
|
310
|
+
const { frame, captured } = makeFrame();
|
|
311
|
+
const onInitialized = vi.fn();
|
|
312
|
+
const bridge = createMcpAppBridge({ frame, handlers: { onInitialized } });
|
|
313
|
+
|
|
314
|
+
deliver(bridge, {
|
|
315
|
+
jsonrpc: "2.0",
|
|
316
|
+
id: 1,
|
|
317
|
+
method: "ui/initialize",
|
|
318
|
+
params: {
|
|
319
|
+
appCapabilities: {
|
|
320
|
+
availableDisplayModes: Object.assign(["inline", "pip"], {
|
|
321
|
+
filter: 0,
|
|
322
|
+
constructor: 0,
|
|
323
|
+
}),
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
});
|
|
327
|
+
deliver(bridge, {
|
|
328
|
+
jsonrpc: "2.0",
|
|
329
|
+
method: "notifications/initialized",
|
|
330
|
+
});
|
|
331
|
+
await flush();
|
|
332
|
+
|
|
333
|
+
expect((captured[0] as McpAppJsonRpcResponse).error).toBeUndefined();
|
|
334
|
+
expect(onInitialized).toHaveBeenCalledWith({
|
|
335
|
+
appCapabilities: { availableDisplayModes: ["inline", "pip"] },
|
|
336
|
+
});
|
|
337
|
+
bridge.dispose();
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("calls onInitialized with empty params when ui/initialize never arrived", () => {
|
|
341
|
+
const { frame } = makeFrame();
|
|
342
|
+
const onInitialized = vi.fn();
|
|
343
|
+
const bridge = createMcpAppBridge({ frame, handlers: { onInitialized } });
|
|
344
|
+
|
|
345
|
+
deliver(bridge, {
|
|
346
|
+
jsonrpc: "2.0",
|
|
347
|
+
method: "notifications/initialized",
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
expect(onInitialized).toHaveBeenCalledWith({});
|
|
351
|
+
bridge.dispose();
|
|
352
|
+
});
|
|
353
|
+
|
|
200
354
|
it("routes tools/call to handler", async () => {
|
|
201
355
|
const { frame, captured } = makeFrame();
|
|
202
356
|
const callTool = vi.fn().mockResolvedValue({ ok: true });
|
package/src/mcp-apps/bridge.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
type McpAppDisplayMode,
|
|
7
7
|
type McpAppHostContext,
|
|
8
8
|
type McpAppHostInfo,
|
|
9
|
+
type McpAppInitializeParams,
|
|
9
10
|
type McpAppJsonRpcMessage,
|
|
10
11
|
type McpAppJsonRpcNotification,
|
|
11
12
|
type McpAppJsonRpcRequest,
|
|
@@ -19,6 +20,50 @@ const VALID_DISPLAY_MODES = [
|
|
|
19
20
|
"pip",
|
|
20
21
|
] as const satisfies readonly McpAppDisplayMode[];
|
|
21
22
|
|
|
23
|
+
function isMcpAppDisplayMode(value: unknown): value is McpAppDisplayMode {
|
|
24
|
+
return VALID_DISPLAY_MODES.some((mode) => mode === value);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isAppInfo(
|
|
28
|
+
value: unknown,
|
|
29
|
+
): value is NonNullable<McpAppInitializeParams["appInfo"]> {
|
|
30
|
+
return (
|
|
31
|
+
isRecord(value) &&
|
|
32
|
+
typeof value.name === "string" &&
|
|
33
|
+
typeof value.version === "string"
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function getAppCapabilities(
|
|
38
|
+
value: unknown,
|
|
39
|
+
): McpAppInitializeParams["appCapabilities"] {
|
|
40
|
+
if (!isRecord(value)) return undefined;
|
|
41
|
+
const { availableDisplayModes, ...rest } = value;
|
|
42
|
+
return {
|
|
43
|
+
...rest,
|
|
44
|
+
// Object.values reads only the entries present, so a sparse list claiming a huge length stays cheap, and the list's own methods are never called.
|
|
45
|
+
...(Array.isArray(availableDisplayModes)
|
|
46
|
+
? {
|
|
47
|
+
availableDisplayModes: Object.values(availableDisplayModes).filter(
|
|
48
|
+
isMcpAppDisplayMode,
|
|
49
|
+
),
|
|
50
|
+
}
|
|
51
|
+
: {}),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function getInitializeParams(params: unknown): McpAppInitializeParams {
|
|
56
|
+
if (!isRecord(params)) return {};
|
|
57
|
+
const appCapabilities = getAppCapabilities(params.appCapabilities);
|
|
58
|
+
return {
|
|
59
|
+
...(isAppInfo(params.appInfo) ? { appInfo: params.appInfo } : {}),
|
|
60
|
+
...(appCapabilities ? { appCapabilities } : {}),
|
|
61
|
+
...(typeof params.protocolVersion === "string"
|
|
62
|
+
? { protocolVersion: params.protocolVersion }
|
|
63
|
+
: {}),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
22
67
|
export type McpAppBridgeFrame = SandboxHostFrame;
|
|
23
68
|
|
|
24
69
|
export type CreateMcpAppBridgeOptions = {
|
|
@@ -91,6 +136,7 @@ export function createMcpAppBridge(
|
|
|
91
136
|
hostContext = {},
|
|
92
137
|
} = opts;
|
|
93
138
|
let disposed = false;
|
|
139
|
+
let initializeParams: McpAppInitializeParams = {};
|
|
94
140
|
|
|
95
141
|
const post = (msg: McpAppJsonRpcMessage) => {
|
|
96
142
|
if (disposed) return;
|
|
@@ -142,10 +188,9 @@ export function createMcpAppBridge(
|
|
|
142
188
|
|
|
143
189
|
switch (normalizeMethod(req.method)) {
|
|
144
190
|
case "ui/initialize": {
|
|
191
|
+
initializeParams = getInitializeParams(params);
|
|
145
192
|
const requestedProtocolVersion =
|
|
146
|
-
|
|
147
|
-
? params.protocolVersion
|
|
148
|
-
: MCP_APP_PROTOCOL_VERSION;
|
|
193
|
+
initializeParams.protocolVersion ?? MCP_APP_PROTOCOL_VERSION;
|
|
149
194
|
respond(req.id, {
|
|
150
195
|
result: {
|
|
151
196
|
protocolVersion: requestedProtocolVersion,
|
|
@@ -360,10 +405,7 @@ export function createMcpAppBridge(
|
|
|
360
405
|
return;
|
|
361
406
|
}
|
|
362
407
|
const modeParams = (params ?? {}) as { mode?: unknown };
|
|
363
|
-
if (
|
|
364
|
-
typeof modeParams.mode !== "string" ||
|
|
365
|
-
!VALID_DISPLAY_MODES.includes(modeParams.mode as McpAppDisplayMode)
|
|
366
|
-
) {
|
|
408
|
+
if (!isMcpAppDisplayMode(modeParams.mode)) {
|
|
367
409
|
errorResponse(
|
|
368
410
|
req.id,
|
|
369
411
|
JSONRPC_ERROR.invalidParams,
|
|
@@ -373,7 +415,7 @@ export function createMcpAppBridge(
|
|
|
373
415
|
}
|
|
374
416
|
respond(req.id, {
|
|
375
417
|
result: await handlers.requestDisplayMode({
|
|
376
|
-
mode: modeParams.mode
|
|
418
|
+
mode: modeParams.mode,
|
|
377
419
|
}),
|
|
378
420
|
});
|
|
379
421
|
return;
|
|
@@ -398,7 +440,7 @@ export function createMcpAppBridge(
|
|
|
398
440
|
try {
|
|
399
441
|
switch (normalizeMethod(note.method)) {
|
|
400
442
|
case "notifications/initialized": {
|
|
401
|
-
handlers.onInitialized?.();
|
|
443
|
+
handlers.onInitialized?.(initializeParams);
|
|
402
444
|
return;
|
|
403
445
|
}
|
|
404
446
|
case "notifications/size_changed": {
|
package/src/mcp-apps/index.ts
CHANGED
package/src/mcp-apps/types.ts
CHANGED
|
@@ -87,6 +87,20 @@ export type McpAppToolCallParams = {
|
|
|
87
87
|
arguments?: Record<string, unknown>;
|
|
88
88
|
};
|
|
89
89
|
|
|
90
|
+
/**
|
|
91
|
+
* The params of the app's `ui/initialize` request, as `onInitialized`
|
|
92
|
+
* receives them. A field that fails validation is omitted, and
|
|
93
|
+
* `availableDisplayModes` keeps only the modes the bridge recognizes.
|
|
94
|
+
*/
|
|
95
|
+
export type McpAppInitializeParams = {
|
|
96
|
+
appInfo?: { name: string; version: string; [k: string]: unknown };
|
|
97
|
+
appCapabilities?: {
|
|
98
|
+
availableDisplayModes?: McpAppDisplayMode[];
|
|
99
|
+
[k: string]: unknown;
|
|
100
|
+
};
|
|
101
|
+
protocolVersion?: string;
|
|
102
|
+
};
|
|
103
|
+
|
|
90
104
|
export type McpAppBridgeHandlers = {
|
|
91
105
|
allowedTools?: readonly string[];
|
|
92
106
|
callTool?: (params: McpAppToolCallParams) => Promise<unknown> | unknown;
|
|
@@ -99,7 +113,7 @@ export type McpAppBridgeHandlers = {
|
|
|
99
113
|
mode: McpAppDisplayMode;
|
|
100
114
|
}) => Promise<{ mode: McpAppDisplayMode }> | { mode: McpAppDisplayMode };
|
|
101
115
|
onSizeChange?: (params: { width?: number; height?: number }) => void;
|
|
102
|
-
onInitialized?: () => void;
|
|
116
|
+
onInitialized?: (params: McpAppInitializeParams) => void;
|
|
103
117
|
onRequestTeardown?: (params: unknown) => void;
|
|
104
118
|
onLog?: (params: unknown) => void;
|
|
105
119
|
onError?: (error: Error) => void;
|
|
@@ -6,9 +6,10 @@ import { createRoot, type Root } from "react-dom/client";
|
|
|
6
6
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
7
7
|
import { ComposerPrimitiveAttachmentDropzone } from "./ComposerAttachmentDropzone";
|
|
8
8
|
|
|
9
|
-
const { addAttachment, threadCapabilities } = vi.hoisted(() => ({
|
|
9
|
+
const { addAttachment, threadCapabilities, threadState } = vi.hoisted(() => ({
|
|
10
10
|
addAttachment: vi.fn<(file: File) => Promise<void>>(),
|
|
11
11
|
threadCapabilities: { attachments: true },
|
|
12
|
+
threadState: { isDisabled: false },
|
|
12
13
|
}));
|
|
13
14
|
|
|
14
15
|
(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
|
|
@@ -25,6 +26,9 @@ vi.mock("@assistant-ui/store", async (importOriginal) => {
|
|
|
25
26
|
getState: () => ({ capabilities: threadCapabilities }),
|
|
26
27
|
},
|
|
27
28
|
}),
|
|
29
|
+
useAuiState: (
|
|
30
|
+
selector: (state: { thread: typeof threadState }) => unknown,
|
|
31
|
+
) => selector({ thread: threadState }),
|
|
28
32
|
};
|
|
29
33
|
});
|
|
30
34
|
|
|
@@ -53,6 +57,7 @@ describe("ComposerPrimitiveAttachmentDropzone", () => {
|
|
|
53
57
|
beforeEach(async () => {
|
|
54
58
|
addAttachment.mockReset();
|
|
55
59
|
threadCapabilities.attachments = true;
|
|
60
|
+
threadState.isDisabled = false;
|
|
56
61
|
container = document.createElement("div");
|
|
57
62
|
document.body.appendChild(container);
|
|
58
63
|
root = createRoot(container);
|
|
@@ -250,6 +255,32 @@ describe("ComposerPrimitiveAttachmentDropzone", () => {
|
|
|
250
255
|
expect(dropzone.hasAttribute("data-dragging")).toBe(false);
|
|
251
256
|
});
|
|
252
257
|
|
|
258
|
+
it("blocks file drops when the thread is disabled", async () => {
|
|
259
|
+
threadState.isDisabled = true;
|
|
260
|
+
await act(async () => {
|
|
261
|
+
root.render(
|
|
262
|
+
<ComposerPrimitiveAttachmentDropzone data-testid="dropzone">
|
|
263
|
+
<div>dropzone</div>
|
|
264
|
+
</ComposerPrimitiveAttachmentDropzone>,
|
|
265
|
+
);
|
|
266
|
+
});
|
|
267
|
+
const dropzone = container.querySelector("[data-testid='dropzone']")!;
|
|
268
|
+
const over = createDragEvent("dragover", ["Files"]);
|
|
269
|
+
const drop = createDropEvent([
|
|
270
|
+
new File(["file"], "photo.png", { type: "image/png" }),
|
|
271
|
+
]);
|
|
272
|
+
|
|
273
|
+
await act(async () => {
|
|
274
|
+
dropzone.dispatchEvent(over);
|
|
275
|
+
dropzone.dispatchEvent(drop);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
expect(over.defaultPrevented).toBe(true);
|
|
279
|
+
expect(drop.defaultPrevented).toBe(true);
|
|
280
|
+
expect(addAttachment).not.toHaveBeenCalled();
|
|
281
|
+
expect(dropzone.hasAttribute("data-dragging")).toBe(false);
|
|
282
|
+
});
|
|
283
|
+
|
|
253
284
|
it("ignores non-file drags", async () => {
|
|
254
285
|
const dropzone = container.querySelector("[data-testid='dropzone']");
|
|
255
286
|
expect(dropzone).not.toBeNull();
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
import { composeEventHandlers } from "radix-ui/internal";
|
|
13
13
|
import { Slot } from "radix-ui";
|
|
14
14
|
import type React from "react";
|
|
15
|
-
import { useAui } from "@assistant-ui/store";
|
|
15
|
+
import { useAui, useAuiState } from "@assistant-ui/store";
|
|
16
16
|
import { renderSlot } from "../../utils/Primitive";
|
|
17
17
|
|
|
18
18
|
export namespace ComposerPrimitiveAttachmentDropzone {
|
|
@@ -30,13 +30,15 @@ export const ComposerPrimitiveAttachmentDropzone = forwardRef<
|
|
|
30
30
|
>(({ disabled, asChild = false, render, children, ...rest }, ref) => {
|
|
31
31
|
const [isDragging, setIsDragging] = useState(false);
|
|
32
32
|
const aui = useAui();
|
|
33
|
+
const disabledByThread = useAuiState((s) => s.thread.isDisabled);
|
|
34
|
+
const isDisabled = disabled || disabledByThread;
|
|
33
35
|
|
|
34
36
|
useEffect(() => {
|
|
35
|
-
if (!
|
|
37
|
+
if (!isDisabled) return;
|
|
36
38
|
// Disabled handlers cannot clear the latch, so reset it when the enabled period ends.
|
|
37
39
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
38
40
|
setIsDragging(false);
|
|
39
|
-
}, [
|
|
41
|
+
}, [isDisabled]);
|
|
40
42
|
|
|
41
43
|
// An unprevented file drop navigates the tab to the file, so file drags are
|
|
42
44
|
// claimed via preventDefault even when the runtime does not support attachments.
|
|
@@ -44,31 +46,31 @@ export const ComposerPrimitiveAttachmentDropzone = forwardRef<
|
|
|
44
46
|
(e: React.DragEvent) => {
|
|
45
47
|
if (!e.dataTransfer.types.includes("Files")) return;
|
|
46
48
|
e.preventDefault();
|
|
47
|
-
if (
|
|
49
|
+
if (isDisabled || !aui.thread.getState().capabilities.attachments) {
|
|
48
50
|
e.dataTransfer.dropEffect = "none";
|
|
49
51
|
return;
|
|
50
52
|
}
|
|
51
53
|
setIsDragging(true);
|
|
52
54
|
},
|
|
53
|
-
[
|
|
55
|
+
[isDisabled, aui],
|
|
54
56
|
);
|
|
55
57
|
|
|
56
58
|
const handleDragOverCapture = useCallback(
|
|
57
59
|
(e: React.DragEvent) => {
|
|
58
60
|
if (!e.dataTransfer.types.includes("Files")) return;
|
|
59
61
|
e.preventDefault();
|
|
60
|
-
if (
|
|
62
|
+
if (isDisabled || !aui.thread.getState().capabilities.attachments) {
|
|
61
63
|
e.dataTransfer.dropEffect = "none";
|
|
62
64
|
return;
|
|
63
65
|
}
|
|
64
66
|
if (!isDragging) setIsDragging(true);
|
|
65
67
|
},
|
|
66
|
-
[
|
|
68
|
+
[isDisabled, isDragging, aui],
|
|
67
69
|
);
|
|
68
70
|
|
|
69
71
|
const handleDragLeaveCapture = useCallback(
|
|
70
72
|
(e: React.DragEvent) => {
|
|
71
|
-
if (
|
|
73
|
+
if (isDisabled) return;
|
|
72
74
|
e.preventDefault();
|
|
73
75
|
const next = e.relatedTarget as Node | null;
|
|
74
76
|
if (next && e.currentTarget.contains(next)) {
|
|
@@ -76,7 +78,7 @@ export const ComposerPrimitiveAttachmentDropzone = forwardRef<
|
|
|
76
78
|
}
|
|
77
79
|
setIsDragging(false);
|
|
78
80
|
},
|
|
79
|
-
[
|
|
81
|
+
[isDisabled],
|
|
80
82
|
);
|
|
81
83
|
|
|
82
84
|
const handleDrop = useCallback(
|
|
@@ -84,7 +86,7 @@ export const ComposerPrimitiveAttachmentDropzone = forwardRef<
|
|
|
84
86
|
setIsDragging(false);
|
|
85
87
|
if (!e.dataTransfer.types.includes("Files")) return;
|
|
86
88
|
e.preventDefault();
|
|
87
|
-
if (
|
|
89
|
+
if (isDisabled) return;
|
|
88
90
|
const files = Array.from(e.dataTransfer.files);
|
|
89
91
|
if (!aui.thread.getState().capabilities.attachments || files.length === 0)
|
|
90
92
|
return;
|
|
@@ -99,11 +101,11 @@ export const ComposerPrimitiveAttachmentDropzone = forwardRef<
|
|
|
99
101
|
}),
|
|
100
102
|
);
|
|
101
103
|
},
|
|
102
|
-
[
|
|
104
|
+
[isDisabled, aui],
|
|
103
105
|
);
|
|
104
106
|
|
|
105
107
|
const mergedProps = {
|
|
106
|
-
...(isDragging && !
|
|
108
|
+
...(isDragging && !isDisabled ? { "data-dragging": "true" } : null),
|
|
107
109
|
...rest,
|
|
108
110
|
onDragEnterCapture: composeEventHandlers(
|
|
109
111
|
rest.onDragEnterCapture,
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/** @vitest-environment jsdom */
|
|
2
|
+
import {
|
|
3
|
+
act,
|
|
4
|
+
cleanup,
|
|
5
|
+
fireEvent,
|
|
6
|
+
render,
|
|
7
|
+
screen,
|
|
8
|
+
} from "@testing-library/react";
|
|
9
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
10
|
+
import {
|
|
11
|
+
AssistantRuntimeProvider,
|
|
12
|
+
useExternalStoreRuntime,
|
|
13
|
+
} from "@assistant-ui/core/react";
|
|
14
|
+
import type { AttachmentAdapter, ThreadMessage } from "@assistant-ui/core";
|
|
15
|
+
import { ComposerPrimitiveAddAttachment } from "./ComposerAddAttachment";
|
|
16
|
+
import { ComposerPrimitiveInput } from "./ComposerInput";
|
|
17
|
+
import { ComposerPrimitiveRoot } from "./ComposerRoot";
|
|
18
|
+
import { ComposerPrimitiveSend } from "./ComposerSend";
|
|
19
|
+
import { ThreadPrimitiveViewportProvider } from "../../context/providers/ThreadViewportProvider";
|
|
20
|
+
|
|
21
|
+
const makeAttachmentAdapter = () => {
|
|
22
|
+
const add = vi.fn(async ({ file }: { file: File }) => ({
|
|
23
|
+
id: file.name,
|
|
24
|
+
type: "file" as const,
|
|
25
|
+
name: file.name,
|
|
26
|
+
contentType: file.type,
|
|
27
|
+
file,
|
|
28
|
+
status: {
|
|
29
|
+
type: "requires-action" as const,
|
|
30
|
+
reason: "composer-send" as const,
|
|
31
|
+
},
|
|
32
|
+
}));
|
|
33
|
+
const adapter: AttachmentAdapter = {
|
|
34
|
+
accept: "*",
|
|
35
|
+
add,
|
|
36
|
+
remove: async () => {},
|
|
37
|
+
send: async (attachment) => ({
|
|
38
|
+
...attachment,
|
|
39
|
+
status: { type: "complete" },
|
|
40
|
+
content: [{ type: "text", text: "file body" }],
|
|
41
|
+
}),
|
|
42
|
+
};
|
|
43
|
+
return { adapter, add };
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const App = ({
|
|
47
|
+
isDisabled,
|
|
48
|
+
onNew,
|
|
49
|
+
attachments,
|
|
50
|
+
}: {
|
|
51
|
+
isDisabled: boolean;
|
|
52
|
+
onNew: () => Promise<void>;
|
|
53
|
+
attachments: AttachmentAdapter;
|
|
54
|
+
}) => {
|
|
55
|
+
const runtime = useExternalStoreRuntime<ThreadMessage>({
|
|
56
|
+
messages: [],
|
|
57
|
+
isDisabled,
|
|
58
|
+
onNew,
|
|
59
|
+
adapters: { attachments },
|
|
60
|
+
});
|
|
61
|
+
return (
|
|
62
|
+
<AssistantRuntimeProvider runtime={runtime}>
|
|
63
|
+
<ThreadPrimitiveViewportProvider>
|
|
64
|
+
<ComposerPrimitiveRoot>
|
|
65
|
+
<ComposerPrimitiveInput aria-label="input" />
|
|
66
|
+
<ComposerPrimitiveAddAttachment>
|
|
67
|
+
attach
|
|
68
|
+
</ComposerPrimitiveAddAttachment>
|
|
69
|
+
<ComposerPrimitiveSend>send</ComposerPrimitiveSend>
|
|
70
|
+
</ComposerPrimitiveRoot>
|
|
71
|
+
</ThreadPrimitiveViewportProvider>
|
|
72
|
+
</AssistantRuntimeProvider>
|
|
73
|
+
);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const button = (name: string) =>
|
|
77
|
+
screen.getByRole("button", { name }) as HTMLButtonElement;
|
|
78
|
+
|
|
79
|
+
const settle = () =>
|
|
80
|
+
act(async () => {
|
|
81
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
afterEach(() => {
|
|
85
|
+
cleanup();
|
|
86
|
+
document.body.querySelectorAll('input[type="file"]').forEach((input) => {
|
|
87
|
+
input.remove();
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("composer on a disabled thread", () => {
|
|
92
|
+
it("blocks sending a draft that was entered before the thread was disabled", async () => {
|
|
93
|
+
const onNew = vi.fn(async () => {});
|
|
94
|
+
const { adapter } = makeAttachmentAdapter();
|
|
95
|
+
const view = render(
|
|
96
|
+
<App isDisabled={false} onNew={onNew} attachments={adapter} />,
|
|
97
|
+
);
|
|
98
|
+
fireEvent.change(screen.getByRole("textbox", { name: "input" }), {
|
|
99
|
+
target: { value: "hello" },
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
view.rerender(<App isDisabled onNew={onNew} attachments={adapter} />);
|
|
103
|
+
await settle();
|
|
104
|
+
|
|
105
|
+
expect(button("send").disabled).toBe(true);
|
|
106
|
+
fireEvent.click(button("send"));
|
|
107
|
+
await settle();
|
|
108
|
+
expect(onNew).not.toHaveBeenCalled();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("disables attachment selection", () => {
|
|
112
|
+
const onNew = vi.fn(async () => {});
|
|
113
|
+
const { adapter, add } = makeAttachmentAdapter();
|
|
114
|
+
render(<App isDisabled onNew={onNew} attachments={adapter} />);
|
|
115
|
+
|
|
116
|
+
expect(button("attach").disabled).toBe(true);
|
|
117
|
+
fireEvent.click(button("attach"));
|
|
118
|
+
expect(document.body.querySelector('input[type="file"]')).toBeNull();
|
|
119
|
+
expect(add).not.toHaveBeenCalled();
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** @vitest-environment jsdom */
|
|
2
|
-
import { act } from "react";
|
|
2
|
+
import { act, version } from "react";
|
|
3
3
|
import type { MouseEvent, ReactNode } from "react";
|
|
4
4
|
import { createPortal } from "react-dom";
|
|
5
5
|
import { createRoot } from "react-dom/client";
|
|
@@ -12,6 +12,8 @@ import { useComposerCompactContextOptional } from "./ComposerCompactContext";
|
|
|
12
12
|
import type { ComposerCompactContextValue } from "./ComposerCompactContext";
|
|
13
13
|
import { ComposerPrimitiveRoot } from "./ComposerRoot";
|
|
14
14
|
|
|
15
|
+
const onReact18 = version.startsWith("18.");
|
|
16
|
+
|
|
15
17
|
type StoreState = {
|
|
16
18
|
composer: {
|
|
17
19
|
attachments: object[];
|
|
@@ -77,6 +79,24 @@ describe("ComposerPrimitiveRoot click-to-focus", () => {
|
|
|
77
79
|
};
|
|
78
80
|
};
|
|
79
81
|
|
|
82
|
+
it("forwards the inert attribute and removes it when disabled", () => {
|
|
83
|
+
const { container, rerender } = render(<ComposerPrimitiveRoot inert />);
|
|
84
|
+
const form = container.querySelector("form");
|
|
85
|
+
|
|
86
|
+
expect(form?.hasAttribute("inert")).toBe(true);
|
|
87
|
+
|
|
88
|
+
rerender(<ComposerPrimitiveRoot inert={false} />);
|
|
89
|
+
expect(form?.hasAttribute("inert")).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it.skipIf(!onReact18)("keeps a string inert on React 18", () => {
|
|
93
|
+
const { container } = render(
|
|
94
|
+
<ComposerPrimitiveRoot inert={"" as unknown as boolean} />,
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
expect(container.querySelector("form")?.hasAttribute("inert")).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
|
|
80
100
|
it("focuses the textarea when primary-button mousedown starts on blank form space", () => {
|
|
81
101
|
const { blankSpace, textarea } = setupComposer();
|
|
82
102
|
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
type MouseEvent,
|
|
12
12
|
useMemo,
|
|
13
13
|
useState,
|
|
14
|
+
version,
|
|
14
15
|
} from "react";
|
|
15
16
|
import { useComposerSend } from "./ComposerSend";
|
|
16
17
|
import { ComposerCompactContext } from "./ComposerCompactContext";
|
|
@@ -56,6 +57,9 @@ export namespace ComposerPrimitiveRoot {
|
|
|
56
57
|
};
|
|
57
58
|
}
|
|
58
59
|
|
|
60
|
+
// React 18 drops a boolean `inert`, so it receives the attribute's string form.
|
|
61
|
+
const isReact18 = version.startsWith("18.");
|
|
62
|
+
|
|
59
63
|
/**
|
|
60
64
|
* The root form container for message composition.
|
|
61
65
|
*
|
|
@@ -79,7 +83,7 @@ export namespace ComposerPrimitiveRoot {
|
|
|
79
83
|
export const ComposerPrimitiveRoot = forwardRef<
|
|
80
84
|
ComposerPrimitiveRoot.Element,
|
|
81
85
|
ComposerPrimitiveRoot.Props
|
|
82
|
-
>(({ onSubmit, onMouseDown, compact, ...rest }, forwardedRef) => {
|
|
86
|
+
>(({ onSubmit, onMouseDown, compact, inert, ...rest }, forwardedRef) => {
|
|
83
87
|
const send = useComposerSend();
|
|
84
88
|
const composerType = useAuiState((s) => s.composer.type);
|
|
85
89
|
|
|
@@ -95,6 +99,8 @@ export const ComposerPrimitiveRoot = forwardRef<
|
|
|
95
99
|
: false,
|
|
96
100
|
);
|
|
97
101
|
const isCompact = stateAllowsCompact && !multiline;
|
|
102
|
+
const inertAttribute =
|
|
103
|
+
isReact18 && typeof inert === "boolean" ? (inert ? "" : undefined) : inert;
|
|
98
104
|
|
|
99
105
|
const handleSubmit = (e: FormEvent) => {
|
|
100
106
|
e.preventDefault();
|
|
@@ -126,6 +132,7 @@ export const ComposerPrimitiveRoot = forwardRef<
|
|
|
126
132
|
<ComposerCompactContext.Provider value={compact ? compactContext : null}>
|
|
127
133
|
<Primitive.form
|
|
128
134
|
{...rest}
|
|
135
|
+
inert={inertAttribute as boolean | undefined}
|
|
129
136
|
data-aui-composer-type={composerType}
|
|
130
137
|
data-compact={isCompact ? "" : undefined}
|
|
131
138
|
ref={forwardedRef}
|
|
@@ -68,6 +68,8 @@ export type Unstable_ComposerInput = {
|
|
|
68
68
|
* disabled={isDisabled}
|
|
69
69
|
* onChange={(e) => setText(e.target.value)}
|
|
70
70
|
* onKeyDown={(e) => {
|
|
71
|
+
* if (e.nativeEvent.isComposing || e.keyCode === 229) return;
|
|
72
|
+
*
|
|
71
73
|
* if (e.key === "Enter" && !e.shiftKey && canSend) {
|
|
72
74
|
* e.preventDefault();
|
|
73
75
|
* send();
|