@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.
Files changed (71) hide show
  1. package/dist/adapters/OpenAIRealtimeVoiceAdapter.d.ts +22 -0
  2. package/dist/adapters/OpenAIRealtimeVoiceAdapter.d.ts.map +1 -0
  3. package/dist/adapters/OpenAIRealtimeVoiceAdapter.js +297 -0
  4. package/dist/adapters/OpenAIRealtimeVoiceAdapter.js.map +1 -0
  5. package/dist/context/stores/ThreadViewport.d.ts +8 -0
  6. package/dist/context/stores/ThreadViewport.d.ts.map +1 -1
  7. package/dist/context/stores/ThreadViewport.js +24 -8
  8. package/dist/context/stores/ThreadViewport.js.map +1 -1
  9. package/dist/index.d.ts +2 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +3 -2
  12. package/dist/legacy-runtime/runtime-cores/utils/MessageRepository.js +1 -1
  13. package/dist/mcp-apps/McpAppRenderer.d.ts.map +1 -1
  14. package/dist/mcp-apps/McpAppRenderer.js +5 -1
  15. package/dist/mcp-apps/McpAppRenderer.js.map +1 -1
  16. package/dist/model-context/frame/useAssistantFrameHost.d.ts.map +1 -1
  17. package/dist/model-context/frame/useAssistantFrameHost.js +67 -46
  18. package/dist/model-context/frame/useAssistantFrameHost.js.map +1 -1
  19. package/dist/model-context/makeAssistantVisible.js +1 -1
  20. package/dist/primitives/composer/ComposerAddAttachment.d.ts.map +1 -1
  21. package/dist/primitives/composer/ComposerAddAttachment.js +28 -10
  22. package/dist/primitives/composer/ComposerAddAttachment.js.map +1 -1
  23. package/dist/primitives/composer/ComposerInput.d.ts +4 -1
  24. package/dist/primitives/composer/ComposerInput.d.ts.map +1 -1
  25. package/dist/primitives/composer/ComposerInput.js +15 -2
  26. package/dist/primitives/composer/ComposerInput.js.map +1 -1
  27. package/dist/primitives/composer/ComposerResume.d.ts +23 -0
  28. package/dist/primitives/composer/ComposerResume.d.ts.map +1 -0
  29. package/dist/primitives/composer/ComposerResume.js +34 -0
  30. package/dist/primitives/composer/ComposerResume.js.map +1 -0
  31. package/dist/primitives/composer/trigger/TriggerPopoverAction.js +1 -1
  32. package/dist/primitives/composer/trigger/TriggerPopoverDirective.js +1 -1
  33. package/dist/primitives/composer/trigger/triggerSelectionResource.d.ts +6 -2
  34. package/dist/primitives/composer/trigger/triggerSelectionResource.d.ts.map +1 -1
  35. package/dist/primitives/composer/trigger/triggerSelectionResource.js +4 -1
  36. package/dist/primitives/composer/trigger/triggerSelectionResource.js.map +1 -1
  37. package/dist/primitives/composer.d.ts +1 -0
  38. package/dist/primitives/composer.d.ts.map +1 -1
  39. package/dist/primitives/composer.js +3 -1
  40. package/dist/primitives/composer.js.map +1 -1
  41. package/dist/primitives/thread/useThreadViewportAutoScroll.d.ts.map +1 -1
  42. package/dist/primitives/thread/useThreadViewportAutoScroll.js +32 -292
  43. package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
  44. package/dist/unstable/useMentionAdapter.js +1 -1
  45. package/dist/utils/Primitive.d.ts +14 -14
  46. package/dist/utils/hooks/useOnResizeContent.js +1 -1
  47. package/package.json +5 -5
  48. package/src/adapters/OpenAIRealtimeVoiceAdapter.test.ts +748 -0
  49. package/src/adapters/OpenAIRealtimeVoiceAdapter.ts +388 -0
  50. package/src/context/stores/ThreadViewport.ts +36 -8
  51. package/src/index.ts +6 -0
  52. package/src/mcp-apps/McpAppRenderer.test.tsx +65 -0
  53. package/src/mcp-apps/McpAppRenderer.tsx +13 -2
  54. package/src/model-context/frame/useAssistantFrameHost.detach.test.tsx +145 -0
  55. package/src/model-context/frame/useAssistantFrameHost.reload.test.tsx +106 -0
  56. package/src/model-context/frame/useAssistantFrameHost.test.tsx +3 -1
  57. package/src/model-context/frame/useAssistantFrameHost.ts +72 -24
  58. package/src/model-context/makeAssistantVisible.test.tsx +25 -37
  59. package/src/primitives/composer/ComposerAddAttachment.test.tsx +1 -0
  60. package/src/primitives/composer/ComposerAddAttachment.thread-scope.test.tsx +202 -0
  61. package/src/primitives/composer/ComposerAddAttachment.ts +11 -1
  62. package/src/primitives/composer/ComposerInput.focus-on-run-start.test.tsx +130 -0
  63. package/src/primitives/composer/ComposerInput.tsx +30 -4
  64. package/src/primitives/composer/ComposerResume.ts +36 -0
  65. package/src/primitives/composer/trigger/triggerSelectionResource.test.ts +24 -0
  66. package/src/primitives/composer/trigger/triggerSelectionResource.ts +14 -3
  67. package/src/primitives/composer/useAuiState.composer.test.tsx +84 -0
  68. package/src/primitives/composer.ts +1 -0
  69. package/src/primitives/thread/useThreadViewportAutoScroll.test.tsx +90 -0
  70. package/src/primitives/thread/useThreadViewportAutoScroll.ts +48 -486
  71. 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
- } as HTMLIFrameElement,
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
- const iframeWindow = iframeRef.current?.contentWindow;
37
- if (!iframeWindow) return;
38
-
39
- const frameHost = new AssistantFrameHost(iframeWindow, targetOrigin);
40
-
41
- const unsubscribe = register(frameHost);
36
+ let active:
37
+ | {
38
+ iframe: HTMLIFrameElement;
39
+ window: Window;
40
+ dispose: () => void;
41
+ }
42
+ | undefined;
42
43
 
43
- return () => {
44
- let cleanupFailed = false;
45
- let cleanupError: unknown;
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
- frameHost.dispose();
56
+ previous?.dispose();
49
57
  } catch (error) {
50
- cleanupFailed = true;
51
- cleanupError = error;
58
+ disposalFailed = true;
59
+ disposalError = error;
52
60
  }
53
61
 
54
- try {
55
- unsubscribe();
56
- } catch (error) {
57
- if (cleanupFailed) {
58
- console.error(
59
- "[assistant-ui] AssistantFrameHost unregistration failed.",
60
- error,
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 (cleanupFailed) throw cleanupError;
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
- const task = getRegisteredTools().edit!.execute({
201
- editId,
202
- value: "updated",
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
- const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
222
-
223
- const task = getRegisteredTools().click!.execute({ clickId });
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
- const clickTask = getRegisteredTools().click!.execute({ clickId });
246
- expect(setTimeoutSpy).toHaveBeenLastCalledWith(
247
- expect.any(Function),
248
- 2000,
247
+ await expectDefaultSettleDelay(() =>
248
+ getRegisteredTools().click!.execute({ clickId }),
249
249
  );
250
- await vi.runAllTimersAsync();
251
- await clickTask;
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
 
@@ -30,6 +30,7 @@ vi.mock("@assistant-ui/store", async (importOriginal) => {
30
30
  return {
31
31
  ...actual,
32
32
  useAui: () => ({
33
+ optional: {},
33
34
  composer: {
34
35
  getState: () => ({ attachmentAccept: "*" }),
35
36
  },
@@ -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
+ });