@assistant-ui/react 0.15.28 → 0.15.29
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/index.d.ts +1 -0
- 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/trigger/TriggerPopoverAction.js +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverDirective.js +1 -1
- package/dist/unstable/useMentionAdapter.js +1 -1
- package/dist/utils/Primitive.d.ts +14 -14
- package/package.json +4 -4
- package/src/adapters/OpenAIRealtimeVoiceAdapter.test.ts +748 -0
- package/src/adapters/OpenAIRealtimeVoiceAdapter.ts +388 -0
- package/src/index.ts +4 -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/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 +29 -3
|
@@ -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
|
};
|
|
@@ -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
|
+
});
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
type ActionButtonProps,
|
|
6
6
|
createActionButton,
|
|
7
7
|
} from "../../utils/createActionButton";
|
|
8
|
-
import { useCallback } from "react";
|
|
8
|
+
import { useCallback, useInsertionEffect, useRef } from "react";
|
|
9
9
|
import { useAui } from "@assistant-ui/store";
|
|
10
10
|
import { useComposerAddAttachment as useComposerAddAttachmentBehavior } from "@assistant-ui/core/react";
|
|
11
11
|
|
|
@@ -17,8 +17,17 @@ const useComposerAddAttachment = ({
|
|
|
17
17
|
} = {}) => {
|
|
18
18
|
const { disabled, addAttachment } = useComposerAddAttachmentBehavior();
|
|
19
19
|
const aui = useAui();
|
|
20
|
+
const scopeGenerationRef = useRef(0);
|
|
21
|
+
|
|
22
|
+
useInsertionEffect(
|
|
23
|
+
() => () => {
|
|
24
|
+
scopeGenerationRef.current += 1;
|
|
25
|
+
},
|
|
26
|
+
[aui.composer, aui.optional.threadListItem],
|
|
27
|
+
);
|
|
20
28
|
|
|
21
29
|
const callback = useCallback(() => {
|
|
30
|
+
const scopeGeneration = scopeGenerationRef.current;
|
|
22
31
|
const input = document.createElement("input");
|
|
23
32
|
input.type = "file";
|
|
24
33
|
input.multiple = multiple;
|
|
@@ -37,6 +46,7 @@ const useComposerAddAttachment = ({
|
|
|
37
46
|
|
|
38
47
|
const attachmentPromises = Array.from(fileList, async (file) => {
|
|
39
48
|
try {
|
|
49
|
+
if (scopeGenerationRef.current !== scopeGeneration) return;
|
|
40
50
|
await addAttachment(file);
|
|
41
51
|
} catch {
|
|
42
52
|
// The composer runtime emits composer.attachmentAddError before rejecting.
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { act, fireEvent, render, screen } from "@testing-library/react";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { AssistantRuntimeProvider } from "../../context";
|
|
6
|
+
import { useLocalRuntime } from "../../legacy-runtime/runtime-cores/local/useLocalRuntime";
|
|
7
|
+
import { ThreadPrimitiveRoot } from "../thread/ThreadRoot";
|
|
8
|
+
import { ComposerPrimitiveInput } from "./ComposerInput";
|
|
9
|
+
import { ComposerPrimitiveRoot } from "./ComposerRoot";
|
|
10
|
+
|
|
11
|
+
const adapter = { async *run() {} };
|
|
12
|
+
|
|
13
|
+
type Runtime = ReturnType<typeof useLocalRuntime>;
|
|
14
|
+
|
|
15
|
+
const setup = () => {
|
|
16
|
+
let runtime: Runtime | null = null;
|
|
17
|
+
const App = () => {
|
|
18
|
+
const currentRuntime = useLocalRuntime(adapter);
|
|
19
|
+
runtime = currentRuntime;
|
|
20
|
+
return (
|
|
21
|
+
<AssistantRuntimeProvider runtime={currentRuntime}>
|
|
22
|
+
<input aria-label="Outside thread" />
|
|
23
|
+
<ThreadPrimitiveRoot>
|
|
24
|
+
<button type="button">Inside thread</button>
|
|
25
|
+
<ComposerPrimitiveInput aria-label="Composer" autoFocus />
|
|
26
|
+
<ThreadPrimitiveRoot>
|
|
27
|
+
<button type="button">Nested thread</button>
|
|
28
|
+
</ThreadPrimitiveRoot>
|
|
29
|
+
</ThreadPrimitiveRoot>
|
|
30
|
+
</AssistantRuntimeProvider>
|
|
31
|
+
);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
render(<App />);
|
|
35
|
+
const composer = screen.getByRole("textbox", {
|
|
36
|
+
name: "Composer",
|
|
37
|
+
}) as HTMLTextAreaElement;
|
|
38
|
+
const startRun = async () => {
|
|
39
|
+
await act(async () => {
|
|
40
|
+
await runtime!.thread.append("Programmatic message");
|
|
41
|
+
});
|
|
42
|
+
};
|
|
43
|
+
return { composer, startRun };
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
describe("ComposerPrimitiveInput run-start focus", () => {
|
|
47
|
+
it("focuses the composer when its button is focused without a thread root", async () => {
|
|
48
|
+
let runtime: Runtime | null = null;
|
|
49
|
+
const App = () => {
|
|
50
|
+
const currentRuntime = useLocalRuntime(adapter);
|
|
51
|
+
runtime = currentRuntime;
|
|
52
|
+
return (
|
|
53
|
+
<AssistantRuntimeProvider runtime={currentRuntime}>
|
|
54
|
+
<ComposerPrimitiveRoot>
|
|
55
|
+
<ComposerPrimitiveInput aria-label="Floating composer" autoFocus />
|
|
56
|
+
<button type="button">Send</button>
|
|
57
|
+
</ComposerPrimitiveRoot>
|
|
58
|
+
</AssistantRuntimeProvider>
|
|
59
|
+
);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
render(<App />);
|
|
63
|
+
const composer = screen.getByRole("textbox", {
|
|
64
|
+
name: "Floating composer",
|
|
65
|
+
});
|
|
66
|
+
const send = screen.getByRole("button", { name: "Send" });
|
|
67
|
+
act(() => send.focus());
|
|
68
|
+
|
|
69
|
+
await act(async () => {
|
|
70
|
+
await runtime!.thread.append("Programmatic message");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
expect(document.activeElement).toBe(composer);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("keeps focus in an input outside the thread", async () => {
|
|
77
|
+
const { startRun } = setup();
|
|
78
|
+
const outside = screen.getByRole("textbox", { name: "Outside thread" });
|
|
79
|
+
act(() => outside.focus());
|
|
80
|
+
|
|
81
|
+
await startRun();
|
|
82
|
+
|
|
83
|
+
expect(document.activeElement).toBe(outside);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("focuses the composer when nothing is focused", async () => {
|
|
87
|
+
const { composer, startRun } = setup();
|
|
88
|
+
act(() => composer.blur());
|
|
89
|
+
expect(document.activeElement).toBe(document.body);
|
|
90
|
+
|
|
91
|
+
await startRun();
|
|
92
|
+
|
|
93
|
+
expect(document.activeElement).toBe(composer);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("focuses the composer when another element in its thread is focused", async () => {
|
|
97
|
+
const { composer, startRun } = setup();
|
|
98
|
+
const inside = screen.getByRole("button", { name: "Inside thread" });
|
|
99
|
+
act(() => inside.focus());
|
|
100
|
+
|
|
101
|
+
await startRun();
|
|
102
|
+
|
|
103
|
+
expect(document.activeElement).toBe(composer);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("keeps focus inside a nested thread root", async () => {
|
|
107
|
+
const { startRun } = setup();
|
|
108
|
+
const nested = screen.getByRole("button", { name: "Nested thread" });
|
|
109
|
+
act(() => nested.focus());
|
|
110
|
+
|
|
111
|
+
await startRun();
|
|
112
|
+
|
|
113
|
+
expect(document.activeElement).toBe(nested);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("preserves the focused composer's selection", async () => {
|
|
117
|
+
const { composer, startRun } = setup();
|
|
118
|
+
fireEvent.change(composer, { target: { value: "hello world" } });
|
|
119
|
+
act(() => {
|
|
120
|
+
composer.focus();
|
|
121
|
+
composer.setSelectionRange(2, 5);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
await startRun();
|
|
125
|
+
|
|
126
|
+
expect(document.activeElement).toBe(composer);
|
|
127
|
+
expect(composer.selectionStart).toBe(2);
|
|
128
|
+
expect(composer.selectionEnd).toBe(5);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -28,6 +28,7 @@ import { flushTapSync } from "@assistant-ui/tap";
|
|
|
28
28
|
import { useComposerInputPluginRegistryOptional } from "./ComposerInputPluginContext";
|
|
29
29
|
import { useComposerCompactContextOptional } from "./ComposerCompactContext";
|
|
30
30
|
import { useComposerCancelWithFocus } from "./useComposerCancelWithFocus";
|
|
31
|
+
import { useThreadRootElementRef } from "../thread/ThreadRootElementContext";
|
|
31
32
|
import {
|
|
32
33
|
useComposerInputDisabled,
|
|
33
34
|
useComposerInputValue,
|
|
@@ -57,7 +58,10 @@ export namespace ComposerPrimitiveInput {
|
|
|
57
58
|
*/
|
|
58
59
|
cancelOnEscape?: boolean | undefined;
|
|
59
60
|
/**
|
|
60
|
-
* Whether to
|
|
61
|
+
* Whether to focus the input when a new run starts if nothing is focused
|
|
62
|
+
* or focus is already inside the same thread or composer. Keeps the current
|
|
63
|
+
* selection when the input is already focused. Applies when `autoFocus` is
|
|
64
|
+
* enabled.
|
|
61
65
|
*
|
|
62
66
|
* @deprecated Experimental since 2024-10-12. Not scheduled for removal; the API may change in any release.
|
|
63
67
|
*
|
|
@@ -189,6 +193,7 @@ export const ComposerPrimitiveInput = forwardRef<
|
|
|
189
193
|
forwardedRef,
|
|
190
194
|
) => {
|
|
191
195
|
const aui = useAui();
|
|
196
|
+
const threadRootRef = useThreadRootElementRef();
|
|
192
197
|
const pluginRegistry = useComposerInputPluginRegistryOptional();
|
|
193
198
|
const compactContext = useComposerCompactContextOptional();
|
|
194
199
|
|
|
@@ -343,6 +348,27 @@ export const ComposerPrimitiveInput = forwardRef<
|
|
|
343
348
|
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
|
|
344
349
|
}, [autoFocusEnabled]);
|
|
345
350
|
|
|
351
|
+
const focusOnRunStart = useCallback(() => {
|
|
352
|
+
const textarea = textareaRef.current;
|
|
353
|
+
if (!textarea) return;
|
|
354
|
+
const doc = textarea.ownerDocument;
|
|
355
|
+
const active = doc.activeElement;
|
|
356
|
+
if (active === textarea) return;
|
|
357
|
+
|
|
358
|
+
const root = threadRootRef?.current;
|
|
359
|
+
const composerRoot = textarea.closest("[data-aui-composer-type]");
|
|
360
|
+
if (
|
|
361
|
+
active &&
|
|
362
|
+
active !== doc.body &&
|
|
363
|
+
!composerRoot?.contains(active) &&
|
|
364
|
+
(!root?.contains(active) ||
|
|
365
|
+
active.closest("[data-aui-thread-root]") !== root)
|
|
366
|
+
)
|
|
367
|
+
return;
|
|
368
|
+
|
|
369
|
+
focus();
|
|
370
|
+
}, [focus, threadRootRef]);
|
|
371
|
+
|
|
346
372
|
useEffect(() => focus(), [focus]);
|
|
347
373
|
|
|
348
374
|
useOnScrollToBottom(() => {
|
|
@@ -361,8 +387,8 @@ export const ComposerPrimitiveInput = forwardRef<
|
|
|
361
387
|
)
|
|
362
388
|
return undefined;
|
|
363
389
|
|
|
364
|
-
return aui.on("thread.runStart",
|
|
365
|
-
}, [unstable_focusOnRunStart,
|
|
390
|
+
return aui.on("thread.runStart", focusOnRunStart);
|
|
391
|
+
}, [unstable_focusOnRunStart, focusOnRunStart, aui]);
|
|
366
392
|
|
|
367
393
|
useEffect(() => {
|
|
368
394
|
if (
|