@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.
Files changed (41) 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/index.d.ts +1 -0
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +3 -2
  8. package/dist/legacy-runtime/runtime-cores/utils/MessageRepository.js +1 -1
  9. package/dist/mcp-apps/McpAppRenderer.d.ts.map +1 -1
  10. package/dist/mcp-apps/McpAppRenderer.js +5 -1
  11. package/dist/mcp-apps/McpAppRenderer.js.map +1 -1
  12. package/dist/model-context/frame/useAssistantFrameHost.d.ts.map +1 -1
  13. package/dist/model-context/frame/useAssistantFrameHost.js +67 -46
  14. package/dist/model-context/frame/useAssistantFrameHost.js.map +1 -1
  15. package/dist/model-context/makeAssistantVisible.js +1 -1
  16. package/dist/primitives/composer/ComposerAddAttachment.d.ts.map +1 -1
  17. package/dist/primitives/composer/ComposerAddAttachment.js +28 -10
  18. package/dist/primitives/composer/ComposerAddAttachment.js.map +1 -1
  19. package/dist/primitives/composer/ComposerInput.d.ts +4 -1
  20. package/dist/primitives/composer/ComposerInput.d.ts.map +1 -1
  21. package/dist/primitives/composer/ComposerInput.js +15 -2
  22. package/dist/primitives/composer/ComposerInput.js.map +1 -1
  23. package/dist/primitives/composer/trigger/TriggerPopoverAction.js +1 -1
  24. package/dist/primitives/composer/trigger/TriggerPopoverDirective.js +1 -1
  25. package/dist/unstable/useMentionAdapter.js +1 -1
  26. package/dist/utils/Primitive.d.ts +14 -14
  27. package/package.json +4 -4
  28. package/src/adapters/OpenAIRealtimeVoiceAdapter.test.ts +748 -0
  29. package/src/adapters/OpenAIRealtimeVoiceAdapter.ts +388 -0
  30. package/src/index.ts +4 -0
  31. package/src/mcp-apps/McpAppRenderer.test.tsx +65 -0
  32. package/src/mcp-apps/McpAppRenderer.tsx +13 -2
  33. package/src/model-context/frame/useAssistantFrameHost.detach.test.tsx +145 -0
  34. package/src/model-context/frame/useAssistantFrameHost.reload.test.tsx +106 -0
  35. package/src/model-context/frame/useAssistantFrameHost.test.tsx +3 -1
  36. package/src/model-context/frame/useAssistantFrameHost.ts +72 -24
  37. package/src/primitives/composer/ComposerAddAttachment.test.tsx +1 -0
  38. package/src/primitives/composer/ComposerAddAttachment.thread-scope.test.tsx +202 -0
  39. package/src/primitives/composer/ComposerAddAttachment.ts +11 -1
  40. package/src/primitives/composer/ComposerInput.focus-on-run-start.test.tsx +130 -0
  41. 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
- } 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
  };
@@ -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
+ });
@@ -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 automatically focus the input when a new run starts.
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", focus);
365
- }, [unstable_focusOnRunStart, focus, aui]);
390
+ return aui.on("thread.runStart", focusOnRunStart);
391
+ }, [unstable_focusOnRunStart, focusOnRunStart, aui]);
366
392
 
367
393
  useEffect(() => {
368
394
  if (