@assistant-ui/react 0.15.28 → 0.15.30
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/adapters/OpenAIRealtimeVoiceAdapter.d.ts +22 -0
- package/dist/adapters/OpenAIRealtimeVoiceAdapter.d.ts.map +1 -0
- package/dist/adapters/OpenAIRealtimeVoiceAdapter.js +297 -0
- package/dist/adapters/OpenAIRealtimeVoiceAdapter.js.map +1 -0
- package/dist/context/stores/ThreadViewport.d.ts +8 -0
- package/dist/context/stores/ThreadViewport.d.ts.map +1 -1
- package/dist/context/stores/ThreadViewport.js +24 -8
- package/dist/context/stores/ThreadViewport.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/legacy-runtime/runtime-cores/utils/MessageRepository.js +1 -1
- package/dist/mcp-apps/McpAppRenderer.d.ts.map +1 -1
- package/dist/mcp-apps/McpAppRenderer.js +5 -1
- package/dist/mcp-apps/McpAppRenderer.js.map +1 -1
- package/dist/model-context/frame/useAssistantFrameHost.d.ts.map +1 -1
- package/dist/model-context/frame/useAssistantFrameHost.js +67 -46
- package/dist/model-context/frame/useAssistantFrameHost.js.map +1 -1
- package/dist/model-context/makeAssistantVisible.js +1 -1
- package/dist/primitives/composer/ComposerAddAttachment.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerAddAttachment.js +28 -10
- package/dist/primitives/composer/ComposerAddAttachment.js.map +1 -1
- package/dist/primitives/composer/ComposerInput.d.ts +4 -1
- package/dist/primitives/composer/ComposerInput.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerInput.js +15 -2
- package/dist/primitives/composer/ComposerInput.js.map +1 -1
- package/dist/primitives/composer/ComposerResume.d.ts +23 -0
- package/dist/primitives/composer/ComposerResume.d.ts.map +1 -0
- package/dist/primitives/composer/ComposerResume.js +34 -0
- package/dist/primitives/composer/ComposerResume.js.map +1 -0
- package/dist/primitives/composer/trigger/TriggerPopoverAction.js +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverDirective.js +1 -1
- package/dist/primitives/composer/trigger/triggerSelectionResource.d.ts +6 -2
- package/dist/primitives/composer/trigger/triggerSelectionResource.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/triggerSelectionResource.js +4 -1
- package/dist/primitives/composer/trigger/triggerSelectionResource.js.map +1 -1
- package/dist/primitives/composer.d.ts +1 -0
- package/dist/primitives/composer.d.ts.map +1 -1
- package/dist/primitives/composer.js +3 -1
- package/dist/primitives/composer.js.map +1 -1
- package/dist/primitives/thread/useThreadViewportAutoScroll.d.ts.map +1 -1
- package/dist/primitives/thread/useThreadViewportAutoScroll.js +32 -292
- package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
- package/dist/unstable/useMentionAdapter.js +1 -1
- package/dist/utils/Primitive.d.ts +14 -14
- package/dist/utils/hooks/useOnResizeContent.js +1 -1
- package/package.json +5 -5
- package/src/adapters/OpenAIRealtimeVoiceAdapter.test.ts +748 -0
- package/src/adapters/OpenAIRealtimeVoiceAdapter.ts +388 -0
- package/src/context/stores/ThreadViewport.ts +36 -8
- package/src/index.ts +6 -0
- package/src/mcp-apps/McpAppRenderer.test.tsx +65 -0
- package/src/mcp-apps/McpAppRenderer.tsx +13 -2
- package/src/model-context/frame/useAssistantFrameHost.detach.test.tsx +145 -0
- package/src/model-context/frame/useAssistantFrameHost.reload.test.tsx +106 -0
- package/src/model-context/frame/useAssistantFrameHost.test.tsx +3 -1
- package/src/model-context/frame/useAssistantFrameHost.ts +72 -24
- package/src/model-context/makeAssistantVisible.test.tsx +25 -37
- package/src/primitives/composer/ComposerAddAttachment.test.tsx +1 -0
- package/src/primitives/composer/ComposerAddAttachment.thread-scope.test.tsx +202 -0
- package/src/primitives/composer/ComposerAddAttachment.ts +11 -1
- package/src/primitives/composer/ComposerInput.focus-on-run-start.test.tsx +130 -0
- package/src/primitives/composer/ComposerInput.tsx +30 -4
- package/src/primitives/composer/ComposerResume.ts +36 -0
- package/src/primitives/composer/trigger/triggerSelectionResource.test.ts +24 -0
- package/src/primitives/composer/trigger/triggerSelectionResource.ts +14 -3
- package/src/primitives/composer/useAuiState.composer.test.tsx +84 -0
- package/src/primitives/composer.ts +1 -0
- package/src/primitives/thread/useThreadViewportAutoScroll.test.tsx +90 -0
- package/src/primitives/thread/useThreadViewportAutoScroll.ts +48 -486
- package/src/model-context/frame/AssistantFrame.test.ts +0 -400
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { renderHook } from "@testing-library/react";
|
|
4
|
+
import { AssistantFrameHost, ModelContextRegistry } from "@assistant-ui/core";
|
|
5
|
+
import { expect, it, vi } from "vitest";
|
|
6
|
+
import { useAssistantFrameHost } from "./useAssistantFrameHost";
|
|
7
|
+
|
|
8
|
+
const attachFrame = () => {
|
|
9
|
+
const iframe = document.createElement("iframe");
|
|
10
|
+
document.body.append(iframe);
|
|
11
|
+
vi.spyOn(iframe.contentWindow!, "postMessage").mockImplementation(() => {});
|
|
12
|
+
return iframe;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const publishTool = (iframe: HTMLIFrameElement, name: string) => {
|
|
16
|
+
window.dispatchEvent(
|
|
17
|
+
new MessageEvent("message", {
|
|
18
|
+
source: iframe.contentWindow,
|
|
19
|
+
origin: window.location.origin,
|
|
20
|
+
data: {
|
|
21
|
+
channel: "assistant-ui-frame",
|
|
22
|
+
message: {
|
|
23
|
+
type: "model-context-update",
|
|
24
|
+
context: {
|
|
25
|
+
tools: {
|
|
26
|
+
[name]: { parameters: { type: "object", properties: {} } },
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
}),
|
|
32
|
+
);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
it("withdraws a detached frame's tools and calls, then serves an attached frame", async () => {
|
|
36
|
+
const registry = new ModelContextRegistry();
|
|
37
|
+
const firstFrame = attachFrame();
|
|
38
|
+
const iframeRef = { current: firstFrame };
|
|
39
|
+
const register = (host: Parameters<typeof registry.addProvider>[0]) =>
|
|
40
|
+
registry.addProvider(host).remove;
|
|
41
|
+
const { unmount } = renderHook(() =>
|
|
42
|
+
useAssistantFrameHost({ iframeRef, register }),
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
publishTool(firstFrame, "search");
|
|
47
|
+
const search = registry.getModelContext().tools?.search;
|
|
48
|
+
expect(search).toBeDefined();
|
|
49
|
+
const pending = search!.execute!({}, {} as any);
|
|
50
|
+
const outcome = pending.then(
|
|
51
|
+
() => undefined,
|
|
52
|
+
(error: unknown) => error,
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
firstFrame.remove();
|
|
56
|
+
await vi.waitFor(() => {
|
|
57
|
+
expect(registry.getModelContext().tools).toBeUndefined();
|
|
58
|
+
});
|
|
59
|
+
expect(await outcome).toMatchObject({
|
|
60
|
+
message: "AssistantFrameHost has been disposed",
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const secondFrame = attachFrame();
|
|
64
|
+
iframeRef.current = secondFrame;
|
|
65
|
+
await vi.waitFor(() => {
|
|
66
|
+
publishTool(secondFrame, "replacement");
|
|
67
|
+
expect(registry.getModelContext().tools?.replacement).toBeDefined();
|
|
68
|
+
});
|
|
69
|
+
secondFrame.remove();
|
|
70
|
+
} finally {
|
|
71
|
+
unmount();
|
|
72
|
+
firstFrame.remove();
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("serves a replacement frame before reporting a disposal failure", () => {
|
|
77
|
+
const disposalError = new Error("tool cancellation failed");
|
|
78
|
+
const dispose = vi
|
|
79
|
+
.spyOn(AssistantFrameHost.prototype, "dispose")
|
|
80
|
+
.mockImplementationOnce(() => {
|
|
81
|
+
throw disposalError;
|
|
82
|
+
});
|
|
83
|
+
const mutationCallbacks: MutationCallback[] = [];
|
|
84
|
+
vi.stubGlobal(
|
|
85
|
+
"MutationObserver",
|
|
86
|
+
class {
|
|
87
|
+
constructor(callback: MutationCallback) {
|
|
88
|
+
mutationCallbacks.push(callback);
|
|
89
|
+
}
|
|
90
|
+
observe() {}
|
|
91
|
+
disconnect() {}
|
|
92
|
+
},
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const registry = new ModelContextRegistry();
|
|
96
|
+
const firstFrame = attachFrame();
|
|
97
|
+
const secondFrame = attachFrame();
|
|
98
|
+
const iframeRef = { current: firstFrame };
|
|
99
|
+
const register = (host: Parameters<typeof registry.addProvider>[0]) =>
|
|
100
|
+
registry.addProvider(host).remove;
|
|
101
|
+
const { unmount } = renderHook(() =>
|
|
102
|
+
useAssistantFrameHost({ iframeRef, register }),
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
publishTool(firstFrame, "search");
|
|
107
|
+
expect(registry.getModelContext().tools?.search).toBeDefined();
|
|
108
|
+
|
|
109
|
+
iframeRef.current = secondFrame;
|
|
110
|
+
expect(() => mutationCallbacks[0]!([], {} as MutationObserver)).toThrow(
|
|
111
|
+
disposalError,
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
expect(registry.getModelContext().tools?.search).toBeUndefined();
|
|
115
|
+
publishTool(secondFrame, "replacement");
|
|
116
|
+
expect(registry.getModelContext().tools?.replacement).toBeDefined();
|
|
117
|
+
} finally {
|
|
118
|
+
unmount();
|
|
119
|
+
firstFrame.remove();
|
|
120
|
+
secondFrame.remove();
|
|
121
|
+
dispose.mockRestore();
|
|
122
|
+
vi.unstubAllGlobals();
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("disposes a host whose registration throws", () => {
|
|
127
|
+
const dispose = vi.spyOn(AssistantFrameHost.prototype, "dispose");
|
|
128
|
+
const iframe = attachFrame();
|
|
129
|
+
const register = vi.fn((): (() => void) => {
|
|
130
|
+
throw new Error("register failed");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
expect(() =>
|
|
135
|
+
renderHook(() =>
|
|
136
|
+
useAssistantFrameHost({ iframeRef: { current: iframe }, register }),
|
|
137
|
+
),
|
|
138
|
+
).toThrow("register failed");
|
|
139
|
+
expect(register).toHaveBeenCalledOnce();
|
|
140
|
+
expect(dispose).toHaveBeenCalledOnce();
|
|
141
|
+
} finally {
|
|
142
|
+
dispose.mockRestore();
|
|
143
|
+
iframe.remove();
|
|
144
|
+
}
|
|
145
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { renderHook } from "@testing-library/react";
|
|
4
|
+
import { ModelContextRegistry } from "@assistant-ui/core";
|
|
5
|
+
import { expect, it, vi } from "vitest";
|
|
6
|
+
import { useAssistantFrameHost } from "./useAssistantFrameHost";
|
|
7
|
+
|
|
8
|
+
it("drops tools and pending calls when a frame loads a page without a provider", async () => {
|
|
9
|
+
vi.useFakeTimers();
|
|
10
|
+
const iframe = document.createElement("iframe");
|
|
11
|
+
document.body.append(iframe);
|
|
12
|
+
vi.spyOn(iframe.contentWindow!, "postMessage").mockImplementation(() => {});
|
|
13
|
+
const registry = new ModelContextRegistry();
|
|
14
|
+
const register = (host: Parameters<typeof registry.addProvider>[0]) =>
|
|
15
|
+
registry.addProvider(host).remove;
|
|
16
|
+
const { unmount } = renderHook(() =>
|
|
17
|
+
useAssistantFrameHost({ iframeRef: { current: iframe }, register }),
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
iframe.dispatchEvent(new Event("load"));
|
|
22
|
+
window.dispatchEvent(
|
|
23
|
+
new MessageEvent("message", {
|
|
24
|
+
source: iframe.contentWindow,
|
|
25
|
+
origin: window.location.origin,
|
|
26
|
+
data: {
|
|
27
|
+
channel: "assistant-ui-frame",
|
|
28
|
+
message: {
|
|
29
|
+
type: "model-context-update",
|
|
30
|
+
context: { tools: { search: { parameters: { type: "object" } } } },
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
}),
|
|
34
|
+
);
|
|
35
|
+
const search = registry.getModelContext().tools?.search;
|
|
36
|
+
expect(search).toBeDefined();
|
|
37
|
+
const outcome = Promise.resolve(search!.execute!({}, {} as any)).catch(
|
|
38
|
+
(error: unknown) => error,
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
iframe.dispatchEvent(new Event("load"));
|
|
42
|
+
expect(registry.getModelContext().tools?.search).toBeDefined();
|
|
43
|
+
vi.advanceTimersByTime(2000);
|
|
44
|
+
expect(registry.getModelContext().tools?.search).toBeUndefined();
|
|
45
|
+
expect(await outcome).toBeInstanceOf(Error);
|
|
46
|
+
} finally {
|
|
47
|
+
unmount();
|
|
48
|
+
iframe.remove();
|
|
49
|
+
vi.restoreAllMocks();
|
|
50
|
+
vi.useRealTimers();
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("clears a reloaded frame when the hook mounted after its initial load", () => {
|
|
55
|
+
vi.useFakeTimers();
|
|
56
|
+
const iframe = document.createElement("iframe");
|
|
57
|
+
document.body.append(iframe);
|
|
58
|
+
const postMessage = vi
|
|
59
|
+
.spyOn(iframe.contentWindow!, "postMessage")
|
|
60
|
+
.mockImplementation(() => {});
|
|
61
|
+
const registry = new ModelContextRegistry();
|
|
62
|
+
const register = (host: Parameters<typeof registry.addProvider>[0]) =>
|
|
63
|
+
registry.addProvider(host).remove;
|
|
64
|
+
const { unmount } = renderHook(() =>
|
|
65
|
+
useAssistantFrameHost({ iframeRef: { current: iframe }, register }),
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
window.dispatchEvent(
|
|
70
|
+
new MessageEvent("message", {
|
|
71
|
+
source: iframe.contentWindow,
|
|
72
|
+
origin: window.location.origin,
|
|
73
|
+
data: {
|
|
74
|
+
channel: "assistant-ui-frame",
|
|
75
|
+
message: {
|
|
76
|
+
type: "model-context-update",
|
|
77
|
+
documentId: "first",
|
|
78
|
+
context: { tools: { search: { parameters: { type: "object" } } } },
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
}),
|
|
82
|
+
);
|
|
83
|
+
expect(registry.getModelContext().tools?.search).toBeDefined();
|
|
84
|
+
postMessage.mockClear();
|
|
85
|
+
|
|
86
|
+
iframe.dispatchEvent(new Event("load"));
|
|
87
|
+
expect(registry.getModelContext().tools?.search).toBeDefined();
|
|
88
|
+
vi.advanceTimersByTime(2000);
|
|
89
|
+
expect(registry.getModelContext().tools?.search).toBeUndefined();
|
|
90
|
+
expect(postMessage).toHaveBeenCalledWith(
|
|
91
|
+
{
|
|
92
|
+
channel: "assistant-ui-frame",
|
|
93
|
+
message: {
|
|
94
|
+
type: "model-context-request",
|
|
95
|
+
requestId: expect.any(String),
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
window.location.origin,
|
|
99
|
+
);
|
|
100
|
+
} finally {
|
|
101
|
+
unmount();
|
|
102
|
+
iframe.remove();
|
|
103
|
+
vi.restoreAllMocks();
|
|
104
|
+
vi.useRealTimers();
|
|
105
|
+
}
|
|
106
|
+
});
|
|
@@ -26,7 +26,9 @@ describe("useAssistantFrameHost", () => {
|
|
|
26
26
|
const iframeRef = {
|
|
27
27
|
current: {
|
|
28
28
|
contentWindow: { postMessage: vi.fn() } as unknown as Window,
|
|
29
|
-
|
|
29
|
+
addEventListener: vi.fn(),
|
|
30
|
+
removeEventListener: vi.fn(),
|
|
31
|
+
} as unknown as HTMLIFrameElement,
|
|
30
32
|
};
|
|
31
33
|
const { unmount } = renderHook(() =>
|
|
32
34
|
useAssistantFrameHost({ iframeRef, register }),
|
|
@@ -33,39 +33,87 @@ export const useAssistantFrameHost = ({
|
|
|
33
33
|
register,
|
|
34
34
|
}: UseAssistantFrameHostOptions): void => {
|
|
35
35
|
useEffect(() => {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
36
|
+
let active:
|
|
37
|
+
| {
|
|
38
|
+
iframe: HTMLIFrameElement;
|
|
39
|
+
window: Window;
|
|
40
|
+
dispose: () => void;
|
|
41
|
+
}
|
|
42
|
+
| undefined;
|
|
42
43
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
const syncFrame = () => {
|
|
45
|
+
const iframe = iframeRef.current;
|
|
46
|
+
const iframeWindow =
|
|
47
|
+
iframe?.isConnected === false ? null : iframe?.contentWindow;
|
|
48
|
+
if (active && active.iframe === iframe && active.window === iframeWindow)
|
|
49
|
+
return;
|
|
46
50
|
|
|
51
|
+
const previous = active;
|
|
52
|
+
active = undefined;
|
|
53
|
+
let disposalFailed = false;
|
|
54
|
+
let disposalError: unknown;
|
|
47
55
|
try {
|
|
48
|
-
|
|
56
|
+
previous?.dispose();
|
|
49
57
|
} catch (error) {
|
|
50
|
-
|
|
51
|
-
|
|
58
|
+
disposalFailed = true;
|
|
59
|
+
disposalError = error;
|
|
52
60
|
}
|
|
53
61
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
} else {
|
|
63
|
-
cleanupFailed = true;
|
|
64
|
-
cleanupError = error;
|
|
62
|
+
if (iframe && iframeWindow) {
|
|
63
|
+
const frameHost = new AssistantFrameHost(iframeWindow, targetOrigin);
|
|
64
|
+
let unsubscribe: Unsubscribe;
|
|
65
|
+
try {
|
|
66
|
+
unsubscribe = register(frameHost);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
frameHost.dispose();
|
|
69
|
+
throw error;
|
|
65
70
|
}
|
|
71
|
+
const onLoad = () => frameHost.frameLoaded();
|
|
72
|
+
iframe.addEventListener("load", onLoad);
|
|
73
|
+
active = {
|
|
74
|
+
iframe,
|
|
75
|
+
window: iframeWindow,
|
|
76
|
+
dispose: () => {
|
|
77
|
+
iframe.removeEventListener("load", onLoad);
|
|
78
|
+
let cleanupFailed = false;
|
|
79
|
+
let cleanupError: unknown;
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
frameHost.dispose();
|
|
83
|
+
} catch (error) {
|
|
84
|
+
cleanupFailed = true;
|
|
85
|
+
cleanupError = error;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
unsubscribe();
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (cleanupFailed) {
|
|
92
|
+
console.error(
|
|
93
|
+
"[assistant-ui] AssistantFrameHost unregistration failed.",
|
|
94
|
+
error,
|
|
95
|
+
);
|
|
96
|
+
} else {
|
|
97
|
+
cleanupFailed = true;
|
|
98
|
+
cleanupError = error;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (cleanupFailed) throw cleanupError;
|
|
103
|
+
},
|
|
104
|
+
};
|
|
66
105
|
}
|
|
67
106
|
|
|
68
|
-
if (
|
|
107
|
+
if (disposalFailed) throw disposalError;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
syncFrame();
|
|
111
|
+
const observer = new MutationObserver(syncFrame);
|
|
112
|
+
observer.observe(document, { childList: true, subtree: true });
|
|
113
|
+
|
|
114
|
+
return () => {
|
|
115
|
+
observer.disconnect();
|
|
116
|
+
active?.dispose();
|
|
69
117
|
};
|
|
70
118
|
}, [iframeRef, targetOrigin, register]);
|
|
71
119
|
};
|
|
@@ -136,6 +136,21 @@ const runEditTool = async (
|
|
|
136
136
|
await task;
|
|
137
137
|
};
|
|
138
138
|
|
|
139
|
+
const expectDefaultSettleDelay = async (start: () => Promise<unknown>) => {
|
|
140
|
+
await act(async () => {
|
|
141
|
+
const settled = vi.fn();
|
|
142
|
+
const task = start();
|
|
143
|
+
void task.then(settled);
|
|
144
|
+
|
|
145
|
+
await vi.advanceTimersByTimeAsync(1999);
|
|
146
|
+
expect(settled).not.toHaveBeenCalled();
|
|
147
|
+
|
|
148
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
149
|
+
expect(settled).toHaveBeenCalledOnce();
|
|
150
|
+
await task;
|
|
151
|
+
});
|
|
152
|
+
};
|
|
153
|
+
|
|
139
154
|
describe("makeAssistantVisible", () => {
|
|
140
155
|
beforeEach(() => {
|
|
141
156
|
registerMock.mockClear();
|
|
@@ -191,21 +206,14 @@ describe("makeAssistantVisible", () => {
|
|
|
191
206
|
});
|
|
192
207
|
const input = render(<EditableInput />).getByRole("textbox");
|
|
193
208
|
const editId = (input as HTMLInputElement).dataset.editId!;
|
|
194
|
-
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
|
195
209
|
|
|
196
210
|
expect(
|
|
197
211
|
(input as HTMLInputElement).dataset.actionSettleDelay,
|
|
198
212
|
).toBeUndefined();
|
|
199
213
|
|
|
200
|
-
|
|
201
|
-
editId,
|
|
202
|
-
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 2000);
|
|
206
|
-
await vi.runAllTimersAsync();
|
|
207
|
-
await task;
|
|
208
|
-
setTimeoutSpy.mockRestore();
|
|
214
|
+
await expectDefaultSettleDelay(() =>
|
|
215
|
+
getRegisteredTools().edit!.execute({ editId, value: "updated" }),
|
|
216
|
+
);
|
|
209
217
|
});
|
|
210
218
|
|
|
211
219
|
it("uses the default when the rendered settle delay is invalid", async () => {
|
|
@@ -218,14 +226,9 @@ describe("makeAssistantVisible", () => {
|
|
|
218
226
|
);
|
|
219
227
|
const clickId = (button as HTMLButtonElement).dataset.clickId!;
|
|
220
228
|
(button as HTMLButtonElement).dataset.actionSettleDelay = "invalid";
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 2000);
|
|
226
|
-
await vi.runAllTimersAsync();
|
|
227
|
-
await task;
|
|
228
|
-
setTimeoutSpy.mockRestore();
|
|
229
|
+
await expectDefaultSettleDelay(() =>
|
|
230
|
+
getRegisteredTools().click!.execute({ clickId }),
|
|
231
|
+
);
|
|
229
232
|
});
|
|
230
233
|
|
|
231
234
|
it.each(["", " "])(
|
|
@@ -239,29 +242,14 @@ describe("makeAssistantVisible", () => {
|
|
|
239
242
|
const input = render(<VisibleInput />).getByRole("textbox");
|
|
240
243
|
const clickId = (input as HTMLInputElement).dataset.clickId!;
|
|
241
244
|
const editId = (input as HTMLInputElement).dataset.editId!;
|
|
242
|
-
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
|
243
245
|
input.dataset.actionSettleDelay = renderedDelay;
|
|
244
246
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
expect.any(Function),
|
|
248
|
-
2000,
|
|
247
|
+
await expectDefaultSettleDelay(() =>
|
|
248
|
+
getRegisteredTools().click!.execute({ clickId }),
|
|
249
249
|
);
|
|
250
|
-
await
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
setTimeoutSpy.mockClear();
|
|
254
|
-
const editTask = getRegisteredTools().edit!.execute({
|
|
255
|
-
editId,
|
|
256
|
-
value: "updated",
|
|
257
|
-
});
|
|
258
|
-
expect(setTimeoutSpy).toHaveBeenLastCalledWith(
|
|
259
|
-
expect.any(Function),
|
|
260
|
-
2000,
|
|
250
|
+
await expectDefaultSettleDelay(() =>
|
|
251
|
+
getRegisteredTools().edit!.execute({ editId, value: "updated" }),
|
|
261
252
|
);
|
|
262
|
-
await vi.runAllTimersAsync();
|
|
263
|
-
await editTask;
|
|
264
|
-
setTimeoutSpy.mockRestore();
|
|
265
253
|
},
|
|
266
254
|
);
|
|
267
255
|
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act, createElement, useState } from "react";
|
|
3
|
+
import { render, waitFor, cleanup } from "@testing-library/react";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { AuiConfig, AuiProvider, Derived, useAui } from "@assistant-ui/store";
|
|
6
|
+
import { resource, useResource } from "@assistant-ui/tap";
|
|
7
|
+
import { InMemoryThreadList } from "@assistant-ui/core/store";
|
|
8
|
+
import {
|
|
9
|
+
AssistantRuntimeProvider,
|
|
10
|
+
ExternalThread,
|
|
11
|
+
SimpleTextAttachmentAdapter,
|
|
12
|
+
Tools,
|
|
13
|
+
useLocalRuntime,
|
|
14
|
+
type AssistantRuntime,
|
|
15
|
+
type ChatModelAdapter,
|
|
16
|
+
} from "../../index";
|
|
17
|
+
import { ComposerPrimitiveAddAttachment } from "./ComposerAddAttachment";
|
|
18
|
+
|
|
19
|
+
const attachmentAdapter = new SimpleTextAttachmentAdapter();
|
|
20
|
+
const model: ChatModelAdapter = { async *run() {} };
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
cleanup();
|
|
24
|
+
document
|
|
25
|
+
.querySelectorAll('input[type="file"]')
|
|
26
|
+
.forEach((input) => input.remove());
|
|
27
|
+
vi.restoreAllMocks();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const StandaloneThread = resource(function useStandaloneThread() {
|
|
31
|
+
return useResource(ExternalThread({ messages: [], attachmentAdapter }));
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const setup = async (mode: "store" | "runtime" | "standalone") => {
|
|
35
|
+
vi.spyOn(HTMLInputElement.prototype, "click").mockImplementation(() => {});
|
|
36
|
+
let captured!: ReturnType<typeof useAui>;
|
|
37
|
+
let runtime: AssistantRuntime | undefined;
|
|
38
|
+
let changeScope!: () => void;
|
|
39
|
+
const Capture = () => {
|
|
40
|
+
captured = useAui();
|
|
41
|
+
return (
|
|
42
|
+
<ComposerPrimitiveAddAttachment>Add file</ComposerPrimitiveAddAttachment>
|
|
43
|
+
);
|
|
44
|
+
};
|
|
45
|
+
const Scope = () => {
|
|
46
|
+
const aui = useAui();
|
|
47
|
+
const [withTools, setWithTools] = useState(false);
|
|
48
|
+
changeScope = () => setWithTools(true);
|
|
49
|
+
const config = AuiConfig(withTools ? { tools: Tools({}) } : {});
|
|
50
|
+
return (
|
|
51
|
+
<AuiProvider extends={aui} config={config}>
|
|
52
|
+
<Capture />
|
|
53
|
+
</AuiProvider>
|
|
54
|
+
);
|
|
55
|
+
};
|
|
56
|
+
const StoreApp = () => {
|
|
57
|
+
const aui = useAui({
|
|
58
|
+
threads: InMemoryThreadList({
|
|
59
|
+
thread: () => ExternalThread({ messages: [], attachmentAdapter }),
|
|
60
|
+
}),
|
|
61
|
+
});
|
|
62
|
+
return (
|
|
63
|
+
<AuiProvider value={aui}>
|
|
64
|
+
<Scope />
|
|
65
|
+
</AuiProvider>
|
|
66
|
+
);
|
|
67
|
+
};
|
|
68
|
+
const RuntimeApp = () => {
|
|
69
|
+
runtime = useLocalRuntime(model, {
|
|
70
|
+
adapters: { attachments: attachmentAdapter },
|
|
71
|
+
});
|
|
72
|
+
return (
|
|
73
|
+
<AssistantRuntimeProvider runtime={runtime}>
|
|
74
|
+
<Scope />
|
|
75
|
+
</AssistantRuntimeProvider>
|
|
76
|
+
);
|
|
77
|
+
};
|
|
78
|
+
const StandaloneApp = () => {
|
|
79
|
+
const config = AuiConfig({
|
|
80
|
+
thread: StandaloneThread(),
|
|
81
|
+
composer: Derived({
|
|
82
|
+
source: "thread",
|
|
83
|
+
query: {},
|
|
84
|
+
get: (aui) => aui.thread.composer(),
|
|
85
|
+
}),
|
|
86
|
+
});
|
|
87
|
+
return (
|
|
88
|
+
<AuiProvider config={config}>
|
|
89
|
+
<Capture />
|
|
90
|
+
</AuiProvider>
|
|
91
|
+
);
|
|
92
|
+
};
|
|
93
|
+
const App =
|
|
94
|
+
mode === "store"
|
|
95
|
+
? StoreApp
|
|
96
|
+
: mode === "runtime"
|
|
97
|
+
? RuntimeApp
|
|
98
|
+
: StandaloneApp;
|
|
99
|
+
const view = render(createElement(App));
|
|
100
|
+
if (runtime)
|
|
101
|
+
await act(async () => {
|
|
102
|
+
await runtime!.threads.mainItem.initialize();
|
|
103
|
+
});
|
|
104
|
+
await act(async () => view.getByRole("button").click());
|
|
105
|
+
const input = document.querySelector<HTMLInputElement>('input[type="file"]')!;
|
|
106
|
+
const select = async () => {
|
|
107
|
+
Object.defineProperty(input, "files", {
|
|
108
|
+
configurable: true,
|
|
109
|
+
value: [
|
|
110
|
+
new File(["private draft"], "thread-a.txt", { type: "text/plain" }),
|
|
111
|
+
],
|
|
112
|
+
});
|
|
113
|
+
const event = new Event("change");
|
|
114
|
+
Object.defineProperty(event, "target", { value: input });
|
|
115
|
+
await act(async () => {
|
|
116
|
+
await input.onchange?.call(input, event);
|
|
117
|
+
});
|
|
118
|
+
};
|
|
119
|
+
return {
|
|
120
|
+
aui: () => captured,
|
|
121
|
+
select,
|
|
122
|
+
changeScope,
|
|
123
|
+
unmount: view.unmount,
|
|
124
|
+
rerender: () => view.rerender(createElement(App)),
|
|
125
|
+
};
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
describe.each(["store", "runtime"] as const)(
|
|
129
|
+
"ComposerPrimitive.AddAttachment thread ownership in %s",
|
|
130
|
+
(mode) => {
|
|
131
|
+
it("adds a selected file to the thread that opened the picker", async () => {
|
|
132
|
+
const { aui, select } = await setup(mode);
|
|
133
|
+
await select();
|
|
134
|
+
expect(
|
|
135
|
+
aui()
|
|
136
|
+
.composer.getState()
|
|
137
|
+
.attachments.map((item) => item.name),
|
|
138
|
+
).toEqual(["thread-a.txt"]);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("does not add an old picker's selection to a different thread", async () => {
|
|
142
|
+
const { aui, select } = await setup(mode);
|
|
143
|
+
const originId = aui().threads.getState().mainThreadId;
|
|
144
|
+
await act(async () => {
|
|
145
|
+
aui().threads.switchToNewThread();
|
|
146
|
+
});
|
|
147
|
+
await waitFor(() =>
|
|
148
|
+
expect(aui().threads.getState().mainThreadId).not.toBe(originId),
|
|
149
|
+
);
|
|
150
|
+
expect(aui().composer.getState().attachments).toHaveLength(0);
|
|
151
|
+
await select();
|
|
152
|
+
expect(aui().composer.getState().attachments).toHaveLength(0);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("keeps the picker usable when the same draft changes", async () => {
|
|
156
|
+
const { aui, select } = await setup(mode);
|
|
157
|
+
await act(async () => {
|
|
158
|
+
aui().composer.setText("same conversation");
|
|
159
|
+
});
|
|
160
|
+
await select();
|
|
161
|
+
expect(aui().composer.getState().text).toBe("same conversation");
|
|
162
|
+
expect(
|
|
163
|
+
aui()
|
|
164
|
+
.composer.getState()
|
|
165
|
+
.attachments.map((item) => item.name),
|
|
166
|
+
).toEqual(["thread-a.txt"]);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("keeps the picker usable when an unrelated scope changes", async () => {
|
|
170
|
+
const { aui, select, changeScope } = await setup(mode);
|
|
171
|
+
const original = aui();
|
|
172
|
+
await act(async () => changeScope());
|
|
173
|
+
expect(Object.is(aui(), original)).toBe(false);
|
|
174
|
+
expect(Object.is(aui().composer, original.composer)).toBe(true);
|
|
175
|
+
await select();
|
|
176
|
+
expect(
|
|
177
|
+
aui()
|
|
178
|
+
.composer.getState()
|
|
179
|
+
.attachments.map((item) => item.name),
|
|
180
|
+
).toEqual(["thread-a.txt"]);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("settles a picker result after its provider unmounts", async () => {
|
|
184
|
+
const { select, unmount } = await setup(mode);
|
|
185
|
+
unmount();
|
|
186
|
+
await expect(select()).resolves.toBeUndefined();
|
|
187
|
+
expect(document.querySelector('input[type="file"]')).toBeNull();
|
|
188
|
+
});
|
|
189
|
+
},
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
it("keeps a standalone composer picker usable after a parent re-render", async () => {
|
|
193
|
+
const { aui, select, rerender } = await setup("standalone");
|
|
194
|
+
expect(aui().optional.threadListItem).toBeUndefined();
|
|
195
|
+
rerender();
|
|
196
|
+
await select();
|
|
197
|
+
expect(
|
|
198
|
+
aui()
|
|
199
|
+
.composer.getState()
|
|
200
|
+
.attachments.map((item) => item.name),
|
|
201
|
+
).toEqual(["thread-a.txt"]);
|
|
202
|
+
});
|