@assistant-ui/react 0.15.22 → 0.15.23

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 (39) hide show
  1. package/LICENSE +1 -1
  2. package/dist/primitives/message/MessageIf.d.ts.map +1 -1
  3. package/dist/primitives/message/MessageIf.js +6 -3
  4. package/dist/primitives/message/MessageIf.js.map +1 -1
  5. package/dist/primitives/thread/ThreadRoot.d.ts +3 -2
  6. package/dist/primitives/thread/ThreadRoot.d.ts.map +1 -1
  7. package/dist/primitives/thread/ThreadRoot.js +47 -27
  8. package/dist/primitives/thread/ThreadRoot.js.map +1 -1
  9. package/dist/primitives/threadListItem/ThreadListItemRoot.d.ts.map +1 -1
  10. package/dist/primitives/threadListItem/ThreadListItemRoot.js +1 -0
  11. package/dist/primitives/threadListItem/ThreadListItemRoot.js.map +1 -1
  12. package/dist/primitives/threadListItemMore/ThreadListItemMoreContent.d.ts.map +1 -1
  13. package/dist/primitives/threadListItemMore/ThreadListItemMoreContent.js +1 -0
  14. package/dist/primitives/threadListItemMore/ThreadListItemMoreContent.js.map +1 -1
  15. package/dist/primitives/threadListItemMore/ThreadListItemMoreTrigger.d.ts.map +1 -1
  16. package/dist/primitives/threadListItemMore/ThreadListItemMoreTrigger.js +1 -0
  17. package/dist/primitives/threadListItemMore/ThreadListItemMoreTrigger.js.map +1 -1
  18. package/dist/unstable/webmcp/WebMcpRegistrationResource.d.ts +1 -0
  19. package/dist/unstable/webmcp/WebMcpRegistrationResource.d.ts.map +1 -1
  20. package/dist/unstable/webmcp/WebMcpRegistrationResource.js +27 -39
  21. package/dist/unstable/webmcp/WebMcpRegistrationResource.js.map +1 -1
  22. package/dist/unstable/webmcp/useWebMcpProvider.d.ts.map +1 -1
  23. package/dist/unstable/webmcp/useWebMcpProvider.js +22 -13
  24. package/dist/unstable/webmcp/useWebMcpProvider.js.map +1 -1
  25. package/package.json +10 -7
  26. package/src/primitives/message/MessageIf.test.tsx +72 -0
  27. package/src/primitives/message/MessageIf.ts +8 -3
  28. package/src/primitives/thread/ThreadRoot.test.tsx +130 -0
  29. package/src/primitives/thread/ThreadRoot.tsx +30 -3
  30. package/src/primitives/threadListItem/ThreadListItemRoot.tsx +2 -0
  31. package/src/primitives/threadListItem/ThreadListKeyboardNav.test.tsx +65 -0
  32. package/src/primitives/threadListItemMore/ThreadListItemMoreContent.tsx +7 -0
  33. package/src/primitives/threadListItemMore/ThreadListItemMoreTrigger.tsx +2 -0
  34. package/src/tests/RemoteThreadListRuntime.reloadMainThread.test.tsx +8 -13
  35. package/src/tests/remote-thread-list-test-helpers.ts +15 -0
  36. package/src/tests/threadListItemIsRunning.test.tsx +10 -7
  37. package/src/unstable/webmcp/WebMcpRegistrationResource.ts +9 -8
  38. package/src/unstable/webmcp/useWebMcpProvider.test.tsx +36 -2
  39. package/src/unstable/webmcp/useWebMcpProvider.ts +40 -13
@@ -1 +1 @@
1
- {"version":3,"file":"useWebMcpProvider.js","names":["c","_c","useEffect","useState","useSyncExternalStore","useResources","useTapRoot","withKey","useAui","AssistantClient","Tool","getDefaultWebMcpHost","WebMcpHost","defaultWebMcpFilter","toWebMcpInputSchema","WebMcpRegistrationResource","shallowEqual","useModelContextSnapshot","ModelContextSnapshotSource","Unstable_WebMcpProviderOptions","filter","name","tool","Unstable_WebMcpProviderResult","status","registeredToolNames","EMPTY_NAMES","Object","freeze","EMPTY_TOOLS","Record","warned","WeakSet","signatures","WeakMap","description","signature","signatureOf","cached","get","JSON","stringify","set","NO_SUBSCRIPTION","modelContextToolSource","empty","read","aui","modelContext","getModelContext","tools","subscribe","onChange","useStableNames","names","cell","next","sort","previous","useWebMcpRegistry","t0","$","host","available","elements","entries","push","t1","error","has","add","console","warn","unstable_useWebMcpProvider","options","root","WebMcpProviderRoot","getValue","published","setPublished"],"sources":["../../../src/unstable/webmcp/useWebMcpProvider.ts"],"sourcesContent":["\"use client\";\n\nimport { useEffect, useState, useSyncExternalStore } from \"react\";\nimport { useResources, useTapRoot, withKey } from \"@assistant-ui/tap\";\nimport { useAui, type AssistantClient } from \"@assistant-ui/store\";\nimport type { Tool } from \"assistant-stream\";\nimport { getDefaultWebMcpHost, type WebMcpHost } from \"./webmcp-host\";\nimport { defaultWebMcpFilter, toWebMcpInputSchema } from \"./convertTools\";\nimport { WebMcpRegistrationResource } from \"./WebMcpRegistrationResource\";\nimport { shallowEqual } from \"@assistant-ui/store/internal\";\nimport {\n useModelContextSnapshot,\n type ModelContextSnapshotSource,\n} from \"../useModelContextSnapshot\";\n\nexport type Unstable_WebMcpProviderOptions = {\n filter?: (name: string, tool: Tool<any, any>) => boolean;\n};\n\nexport type Unstable_WebMcpProviderResult = {\n status: \"unsupported\" | \"active\";\n registeredToolNames: readonly string[];\n};\n\nconst EMPTY_NAMES: readonly string[] = Object.freeze([]);\nconst EMPTY_TOOLS: Record<string, Tool<any, any>> = Object.freeze({});\n\n// The description is re-read on every sync so mutating it in place is\n// observed; the schema is converted only when the tool object itself changes.\n// Keyed on the tool so a schema or filter that throws warns once instead of\n// on every model-context notify.\nconst warned = new WeakSet<Tool<any, any>>();\n\nconst signatures = new WeakMap<\n Tool<any, any>,\n { description: string | undefined; signature: string }\n>();\n\nconst signatureOf = (tool: Tool<any, any>) => {\n const cached = signatures.get(tool);\n if (cached && cached.description === tool.description)\n return cached.signature;\n const signature = `${tool.description ?? \"\"}\\u0000${JSON.stringify(\n toWebMcpInputSchema(tool),\n )}`;\n signatures.set(tool, { description: tool.description, signature });\n return signature;\n};\n\nconst NO_SUBSCRIPTION = () => {};\n\n// No `isEqual`: a caller republishing an unchanged tool set is how it asks for\n// a re-sync, which is what re-attempts a tool whose filter has stopped throwing\n// and what picks up a description edited in place on a stable tool object.\nconst modelContextToolSource: ModelContextSnapshotSource<\n Record<string, Tool<any, any>>\n> = {\n empty: EMPTY_TOOLS,\n read: (aui) => aui.modelContext.getModelContext().tools ?? EMPTY_TOOLS,\n subscribe: (aui, onChange) =>\n aui.modelContext.subscribe?.(onChange) ?? NO_SUBSCRIPTION,\n};\n\nconst useStableNames = (names: readonly (string | null)[]) => {\n const [cell] = useState(() => ({ names: EMPTY_NAMES }));\n const next = names.filter((name): name is string => name !== null).sort();\n const previous = cell.names;\n if (shallowEqual(previous, next)) {\n return previous;\n }\n cell.names = next;\n return next;\n};\n\nconst useWebMcpRegistry = ({\n aui,\n host,\n filter,\n}: {\n aui: AssistantClient;\n host: WebMcpHost;\n filter: (name: string, tool: Tool<any, any>) => boolean;\n}) => {\n const tools = useModelContextSnapshot(\n aui,\n host.available,\n modelContextToolSource,\n );\n\n const elements = [];\n for (const [name, tool] of Object.entries(tools)) {\n try {\n if (!filter(name, tool)) continue;\n const signature = signatureOf(tool);\n elements.push(\n withKey(\n name,\n WebMcpRegistrationResource({ host, name, signature, tool }),\n [host, name, signature, tool],\n ),\n );\n } catch (error) {\n if (warned.has(tool)) continue;\n warned.add(tool);\n console.warn(\n `[assistant-ui] Skipping WebMCP registration for tool \"${name}\": filter or schema conversion failed.`,\n error,\n );\n }\n }\n\n return useStableNames(useResources(elements));\n};\n\n/**\n * Publishes the frontend tools in the model context to a WebMCP-capable\n * browser, so the user's own browser agent can call them.\n *\n * Returns `status: \"unsupported\"` when the page exposes no\n * `document.modelContext` (or `navigator.modelContext`), and the sorted names\n * of the tools currently registered with the host.\n */\nexport const unstable_useWebMcpProvider = (\n options: Unstable_WebMcpProviderOptions = {},\n): Unstable_WebMcpProviderResult => {\n const aui = useAui();\n const [host] = useState(getDefaultWebMcpHost);\n const filter = options.filter ?? defaultWebMcpFilter;\n\n const root = useTapRoot(function WebMcpProviderRoot() {\n return useWebMcpRegistry({ aui, host, filter });\n });\n const registeredToolNames = useSyncExternalStore(\n root.subscribe,\n root.getValue,\n () => EMPTY_NAMES,\n );\n\n const [published, setPublished] = useState(false);\n useEffect(() => {\n if (host.available) setPublished(true);\n }, [host]);\n\n return {\n status: published ? \"active\" : \"unsupported\",\n registeredToolNames,\n };\n};\n"],"mappings":"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"}
1
+ {"version":3,"file":"useWebMcpProvider.js","names":["c","_c","useCallback","useEffect","useInsertionEffect","useRef","useState","useSyncExternalStore","useResources","useTapRoot","withKey","useAui","Tool","getDefaultWebMcpHost","WebMcpHost","defaultWebMcpFilter","toWebMcpInputSchema","WebMcpRegistrationResource","shallowEqual","useModelContextSnapshot","ModelContextSnapshotSource","Unstable_WebMcpProviderOptions","filter","name","tool","Unstable_WebMcpProviderResult","status","registeredToolNames","EMPTY_NAMES","Object","freeze","EMPTY_TOOLS","Record","warned","WeakSet","signatures","WeakMap","description","signature","signatureOf","cached","get","JSON","stringify","set","NO_SUBSCRIPTION","modelContextToolSource","empty","read","aui","modelContext","getModelContext","tools","subscribe","onChange","useStableNames","names","cell","next","sort","previous","useWebMcpRegistry","t0","$","host","getCurrentTool","elements","entries","push","t1","error","has","add","console","warn","unstable_useWebMcpProvider","options","available","currentToolsRef","current","root","WebMcpProviderRoot","getValue","published","setPublished"],"sources":["../../../src/unstable/webmcp/useWebMcpProvider.ts"],"sourcesContent":["\"use client\";\n\nimport {\n useCallback,\n useEffect,\n useInsertionEffect,\n useRef,\n useState,\n useSyncExternalStore,\n} from \"react\";\nimport { useResources, useTapRoot, withKey } from \"@assistant-ui/tap\";\nimport { useAui } from \"@assistant-ui/store\";\nimport type { Tool } from \"assistant-stream\";\nimport { getDefaultWebMcpHost, type WebMcpHost } from \"./webmcp-host\";\nimport { defaultWebMcpFilter, toWebMcpInputSchema } from \"./convertTools\";\nimport { WebMcpRegistrationResource } from \"./WebMcpRegistrationResource\";\nimport { shallowEqual } from \"@assistant-ui/store/internal\";\nimport {\n useModelContextSnapshot,\n type ModelContextSnapshotSource,\n} from \"../useModelContextSnapshot\";\n\nexport type Unstable_WebMcpProviderOptions = {\n filter?: (name: string, tool: Tool<any, any>) => boolean;\n};\n\nexport type Unstable_WebMcpProviderResult = {\n status: \"unsupported\" | \"active\";\n registeredToolNames: readonly string[];\n};\n\nconst EMPTY_NAMES: readonly string[] = Object.freeze([]);\nconst EMPTY_TOOLS: Record<string, Tool<any, any>> = Object.freeze({});\n\n// The description is re-read on every sync so mutating it in place is\n// observed; the schema is converted only when the tool object itself changes.\n// Keyed on the tool so a schema or filter that throws warns once instead of\n// on every model-context notify.\nconst warned = new WeakSet<Tool<any, any>>();\n\nconst signatures = new WeakMap<\n Tool<any, any>,\n { description: string | undefined; signature: string }\n>();\n\nconst signatureOf = (tool: Tool<any, any>) => {\n const cached = signatures.get(tool);\n if (cached && cached.description === tool.description)\n return cached.signature;\n const signature = `${tool.description ?? \"\"}\\u0000${JSON.stringify(\n toWebMcpInputSchema(tool),\n )}`;\n signatures.set(tool, { description: tool.description, signature });\n return signature;\n};\n\nconst NO_SUBSCRIPTION = () => {};\n\n// No `isEqual`: a caller republishing an unchanged tool set is how it asks for\n// a re-sync, which is what re-attempts a tool whose filter has stopped throwing\n// and what picks up a description edited in place on a stable tool object.\nconst modelContextToolSource: ModelContextSnapshotSource<\n Record<string, Tool<any, any>>\n> = {\n empty: EMPTY_TOOLS,\n read: (aui) => aui.modelContext.getModelContext().tools ?? EMPTY_TOOLS,\n subscribe: (aui, onChange) =>\n aui.modelContext.subscribe?.(onChange) ?? NO_SUBSCRIPTION,\n};\n\nconst useStableNames = (names: readonly (string | null)[]) => {\n const [cell] = useState(() => ({ names: EMPTY_NAMES }));\n const next = names.filter((name): name is string => name !== null).sort();\n const previous = cell.names;\n if (shallowEqual(previous, next)) {\n return previous;\n }\n cell.names = next;\n return next;\n};\n\nconst useWebMcpRegistry = ({\n host,\n filter,\n tools,\n getCurrentTool,\n}: {\n host: WebMcpHost;\n filter: (name: string, tool: Tool<any, any>) => boolean;\n tools: Record<string, Tool<any, any>>;\n getCurrentTool: (name: string) => Tool<any, any> | undefined;\n}) => {\n const elements = [];\n for (const [name, tool] of Object.entries(tools)) {\n try {\n if (!filter(name, tool)) continue;\n const signature = signatureOf(tool);\n elements.push(\n withKey(\n name,\n WebMcpRegistrationResource({\n host,\n name,\n signature,\n tool,\n getCurrentTool,\n }),\n [host, name, signature, tool, getCurrentTool],\n ),\n );\n } catch (error) {\n if (warned.has(tool)) continue;\n warned.add(tool);\n console.warn(\n `[assistant-ui] Skipping WebMCP registration for tool \"${name}\": filter or schema conversion failed.`,\n error,\n );\n }\n }\n\n return useStableNames(useResources(elements));\n};\n\n/**\n * Publishes the frontend tools in the model context to a WebMCP-capable\n * browser, so the user's own browser agent can call them.\n *\n * Returns `status: \"unsupported\"` when the page exposes no\n * `document.modelContext` (or `navigator.modelContext`), and the sorted names\n * of the tools currently registered with the host.\n */\nexport const unstable_useWebMcpProvider = (\n options: Unstable_WebMcpProviderOptions = {},\n): Unstable_WebMcpProviderResult => {\n const aui = useAui();\n const [host] = useState(getDefaultWebMcpHost);\n const filter = options.filter ?? defaultWebMcpFilter;\n const tools = useModelContextSnapshot(\n aui,\n host.available,\n modelContextToolSource,\n );\n const currentToolsRef = useRef(tools);\n useInsertionEffect(() => {\n currentToolsRef.current = tools;\n }, [tools]);\n const getCurrentTool = useCallback(\n (name: string) => currentToolsRef.current[name],\n [],\n );\n\n const root = useTapRoot(function WebMcpProviderRoot() {\n return useWebMcpRegistry({\n host,\n filter,\n tools,\n getCurrentTool,\n });\n });\n const registeredToolNames = useSyncExternalStore(\n root.subscribe,\n root.getValue,\n () => EMPTY_NAMES,\n );\n\n const [published, setPublished] = useState(false);\n useEffect(() => {\n if (host.available) setPublished(true);\n }, [host]);\n\n return {\n status: published ? \"active\" : \"unsupported\",\n registeredToolNames,\n };\n};\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assistant-ui/react",
3
- "version": "0.15.22",
3
+ "version": "0.15.23",
4
4
  "description": "Open-source TypeScript/React library for building production-grade AI chat experiences",
5
5
  "keywords": [
6
6
  "ai",
@@ -55,11 +55,11 @@
55
55
  ],
56
56
  "sideEffects": false,
57
57
  "dependencies": {
58
- "@assistant-ui/core": "^0.3.21",
59
- "@assistant-ui/store": "^0.3.15",
60
- "@assistant-ui/tap": "^0.9.19",
61
- "assistant-cloud": "^0.2.3",
62
- "assistant-stream": "^0.3.45",
58
+ "@assistant-ui/core": "^0.3.22",
59
+ "@assistant-ui/store": "^0.3.16",
60
+ "@assistant-ui/tap": "^0.9.20",
61
+ "assistant-cloud": "^0.2.4",
62
+ "assistant-stream": "^0.3.46",
63
63
  "radix-ui": "^1.6.7",
64
64
  "react-textarea-autosize": "^8.5.9",
65
65
  "safe-content-frame": "^0.0.31",
@@ -83,6 +83,7 @@
83
83
  "devDependencies": {
84
84
  "@assistant-ui/vite": "0.0.19",
85
85
  "@assistant-ui/x-buildutils": "0.0.29",
86
+ "@assistant-ui/x-react18": "0.0.0",
86
87
  "@testing-library/react": "^16.3.3",
87
88
  "@types/json-schema": "^7.0.15",
88
89
  "@types/node": "^26.5.1",
@@ -110,6 +111,8 @@
110
111
  "build": "aui-build",
111
112
  "test": "vitest run",
112
113
  "typecheck": "tsc --noEmit",
113
- "test:watch": "vitest"
114
+ "test:peer-react18": "vitest run --config vitest.peer-react18.config.ts",
115
+ "test:watch": "vitest",
116
+ "test:types:peer-react18": "aui-react18-types"
114
117
  }
115
118
  }
@@ -0,0 +1,72 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { render } from "@testing-library/react";
4
+ import { describe, expect, it } from "vitest";
5
+ import type { ThreadMessageLike } from "@assistant-ui/core";
6
+ import {
7
+ AssistantRuntimeProvider,
8
+ useExternalStoreRuntime,
9
+ } from "@assistant-ui/core/react";
10
+ import { useAuiState } from "@assistant-ui/store";
11
+ import { ThreadPrimitiveMessages } from "../thread/ThreadMessages";
12
+ import { ThreadPrimitiveRoot } from "../thread/ThreadRoot";
13
+ import { MessagePrimitiveIf } from "./MessageIf";
14
+
15
+ const messages: ThreadMessageLike[] = [
16
+ { id: "u", role: "user", content: [{ type: "text", text: "u" }] },
17
+ { id: "a", role: "assistant", content: [{ type: "text", text: "a" }] },
18
+ { id: "s", role: "system", content: [{ type: "text", text: "s" }] },
19
+ ];
20
+
21
+ const roles = ["user", "assistant", "system"] as const;
22
+ type Role = (typeof roles)[number];
23
+
24
+ const roleFilter = (role: Role, value: boolean | undefined) =>
25
+ ({ [role]: value }) as MessagePrimitiveIf.Props;
26
+
27
+ const renderRoles = (filter: MessagePrimitiveIf.Props) => {
28
+ const Message = () => {
29
+ const role = useAuiState((s) => s.message.role);
30
+ return (
31
+ <MessagePrimitiveIf {...filter}>
32
+ <span data-testid={`shown-${role}`} />
33
+ </MessagePrimitiveIf>
34
+ );
35
+ };
36
+ const Example = () => {
37
+ const runtime = useExternalStoreRuntime({
38
+ messages,
39
+ convertMessage: (message) => message,
40
+ onNew: async () => {},
41
+ });
42
+ return (
43
+ <AssistantRuntimeProvider runtime={runtime}>
44
+ <ThreadPrimitiveRoot>
45
+ <ThreadPrimitiveMessages components={{ Message }} />
46
+ </ThreadPrimitiveRoot>
47
+ </AssistantRuntimeProvider>
48
+ );
49
+ };
50
+ const { queryByTestId } = render(<Example />);
51
+ return roles.filter((role) => queryByTestId(`shown-${role}`) !== null);
52
+ };
53
+
54
+ describe("MessagePrimitive.If role filters", () => {
55
+ it.each(roles)("%s={true} renders only that role", (role) => {
56
+ expect(renderRoles(roleFilter(role, true))).toEqual([role]);
57
+ });
58
+
59
+ it.each(roles)("%s={false} renders every other role", (role) => {
60
+ expect(renderRoles(roleFilter(role, false))).toEqual(
61
+ roles.filter((r) => r !== role),
62
+ );
63
+ });
64
+
65
+ it.each(roles)("%s={undefined} renders every role", (role) => {
66
+ expect(renderRoles(roleFilter(role, undefined))).toEqual([...roles]);
67
+ });
68
+
69
+ it("combines a true filter with a false filter", () => {
70
+ expect(renderRoles({ assistant: false, system: false })).toEqual(["user"]);
71
+ });
72
+ });
@@ -34,9 +34,14 @@ const useMessageIf = (props: UseMessageIfProps) => {
34
34
 
35
35
  if (props.hasBranches === true && branchCount < 2) return false;
36
36
 
37
- if (props.user && role !== "user") return false;
38
- if (props.assistant && role !== "assistant") return false;
39
- if (props.system && role !== "system") return false;
37
+ if (props.user === true && role !== "user") return false;
38
+ if (props.user === false && role === "user") return false;
39
+
40
+ if (props.assistant === true && role !== "assistant") return false;
41
+ if (props.assistant === false && role === "assistant") return false;
42
+
43
+ if (props.system === true && role !== "system") return false;
44
+ if (props.system === false && role === "system") return false;
40
45
 
41
46
  if (props.lastOrHover === true && !isHovering && !isLast) return false;
42
47
  if (props.last !== undefined && props.last !== isLast) return false;
@@ -94,6 +94,7 @@ const dispatchEscape = (target: EventTarget) => {
94
94
  key: "Escape",
95
95
  bubbles: true,
96
96
  cancelable: true,
97
+ composed: true,
97
98
  });
98
99
  act(() => target.dispatchEvent(event));
99
100
  return event;
@@ -143,6 +144,135 @@ describe("ThreadPrimitiveRoot", () => {
143
144
  expect(event.defaultPrevented).toBe(false);
144
145
  });
145
146
 
147
+ it("does not stop speech in another mounted thread", async () => {
148
+ const firstSpeech = createSpeechAdapter();
149
+ const secondSpeech = createSpeechAdapter();
150
+ const firstRuntimeRef: RuntimeRef = { current: null };
151
+ const secondRuntimeRef: RuntimeRef = { current: null };
152
+ render(
153
+ <>
154
+ <RuntimeProvider
155
+ runtimeRef={firstRuntimeRef}
156
+ speech={firstSpeech.adapter}
157
+ >
158
+ <ThreadPrimitiveRoot>
159
+ <button data-testid="first-thread-control" />
160
+ </ThreadPrimitiveRoot>
161
+ </RuntimeProvider>
162
+ <RuntimeProvider
163
+ runtimeRef={secondRuntimeRef}
164
+ speech={secondSpeech.adapter}
165
+ >
166
+ <ThreadPrimitiveRoot>
167
+ <button data-testid="second-thread-control" />
168
+ </ThreadPrimitiveRoot>
169
+ </RuntimeProvider>
170
+ </>,
171
+ );
172
+ startSpeaking(firstRuntimeRef);
173
+ await waitFor(() => {
174
+ expect(firstRuntimeRef.current!.thread.getState().speech).toBeDefined();
175
+ });
176
+
177
+ const event = dispatchEscape(screen.getByTestId("second-thread-control"));
178
+
179
+ expect(firstSpeech.cancel).not.toHaveBeenCalled();
180
+ expect(secondSpeech.cancel).not.toHaveBeenCalled();
181
+ expect(event.defaultPrevented).toBe(false);
182
+ });
183
+
184
+ it("stops speech in the thread containing the Escape target", async () => {
185
+ const firstSpeech = createSpeechAdapter();
186
+ const secondSpeech = createSpeechAdapter();
187
+ const firstRuntimeRef: RuntimeRef = { current: null };
188
+ const secondRuntimeRef: RuntimeRef = { current: null };
189
+ render(
190
+ <>
191
+ <RuntimeProvider
192
+ runtimeRef={firstRuntimeRef}
193
+ speech={firstSpeech.adapter}
194
+ >
195
+ <ThreadPrimitiveRoot>
196
+ <button data-testid="first-thread-control" />
197
+ </ThreadPrimitiveRoot>
198
+ </RuntimeProvider>
199
+ <RuntimeProvider
200
+ runtimeRef={secondRuntimeRef}
201
+ speech={secondSpeech.adapter}
202
+ >
203
+ <ThreadPrimitiveRoot>
204
+ <button data-testid="second-thread-control" />
205
+ </ThreadPrimitiveRoot>
206
+ </RuntimeProvider>
207
+ </>,
208
+ );
209
+ startSpeaking(firstRuntimeRef);
210
+ startSpeaking(secondRuntimeRef);
211
+ await waitFor(() => {
212
+ expect(firstRuntimeRef.current!.thread.getState().speech).toBeDefined();
213
+ expect(secondRuntimeRef.current!.thread.getState().speech).toBeDefined();
214
+ });
215
+
216
+ const event = dispatchEscape(screen.getByTestId("second-thread-control"));
217
+
218
+ expect(firstSpeech.cancel).not.toHaveBeenCalled();
219
+ expect(secondSpeech.cancel).toHaveBeenCalledOnce();
220
+ expect(event.defaultPrevented).toBe(true);
221
+ });
222
+
223
+ it("stops speech in a thread mounted inside a closed shadow root", async () => {
224
+ const firstSpeech = createSpeechAdapter();
225
+ const secondSpeech = createSpeechAdapter();
226
+ const firstRuntimeRef: RuntimeRef = { current: null };
227
+ const secondRuntimeRef: RuntimeRef = { current: null };
228
+ render(
229
+ <RuntimeProvider
230
+ runtimeRef={firstRuntimeRef}
231
+ speech={firstSpeech.adapter}
232
+ >
233
+ <ThreadPrimitiveRoot />
234
+ </RuntimeProvider>,
235
+ );
236
+ const shadowHost = document.createElement("div");
237
+ const shadowRoot = shadowHost.attachShadow({ mode: "closed" });
238
+ const shadowContainer = document.createElement("div");
239
+ shadowRoot.append(shadowContainer);
240
+ document.body.append(shadowHost);
241
+ const shadowView = render(
242
+ <RuntimeProvider
243
+ runtimeRef={secondRuntimeRef}
244
+ speech={secondSpeech.adapter}
245
+ >
246
+ <ThreadPrimitiveRoot>
247
+ <button data-testid="shadow-thread-control" />
248
+ </ThreadPrimitiveRoot>
249
+ </RuntimeProvider>,
250
+ { container: shadowContainer },
251
+ );
252
+
253
+ try {
254
+ startSpeaking(firstRuntimeRef);
255
+ startSpeaking(secondRuntimeRef);
256
+ await waitFor(() => {
257
+ expect(firstRuntimeRef.current!.thread.getState().speech).toBeDefined();
258
+ expect(
259
+ secondRuntimeRef.current!.thread.getState().speech,
260
+ ).toBeDefined();
261
+ });
262
+
263
+ const event = dispatchEscape(
264
+ shadowContainer.querySelector("[data-testid=shadow-thread-control]")!,
265
+ );
266
+
267
+ expect(firstSpeech.cancel).not.toHaveBeenCalled();
268
+ expect(secondSpeech.cancel).toHaveBeenCalledOnce();
269
+ expect(event.defaultPrevented).toBe(true);
270
+ } finally {
271
+ shadowView.unmount();
272
+ shadowHost.remove();
273
+ }
274
+ });
275
+
146
276
  it("stops speech from outside the only mounted thread", async () => {
147
277
  const speech = createSpeechAdapter();
148
278
  const runtimeRef: RuntimeRef = { current: null };
@@ -5,6 +5,7 @@ import {
5
5
  type ComponentRef,
6
6
  forwardRef,
7
7
  type ComponentPropsWithoutRef,
8
+ type KeyboardEventHandler,
8
9
  useEffect,
9
10
  useRef,
10
11
  } from "react";
@@ -12,6 +13,8 @@ import { useAui } from "@assistant-ui/store";
12
13
  import { useComposedRefs } from "radix-ui/internal";
13
14
  import { ThreadRootElementContext } from "./ThreadRootElementContext";
14
15
 
16
+ const escapeEventThreadRoots = new WeakMap<KeyboardEvent, Element>();
17
+
15
18
  export namespace ThreadPrimitiveRoot {
16
19
  export type Element = ComponentRef<typeof Primitive.div>;
17
20
  /**
@@ -27,8 +30,9 @@ export namespace ThreadPrimitiveRoot {
27
30
  * This component serves as the foundational wrapper for all thread-related components.
28
31
  * It provides the basic structure and context needed for thread functionality.
29
32
  *
30
- * While this component is mounted, an unhandled Escape keydown stops active speech, even if
31
- * the action bar that started it is no longer mounted.
33
+ * While this component is mounted, an unhandled Escape keydown inside this thread stops its
34
+ * active speech, even if the action bar that started it is no longer mounted. Escape outside
35
+ * every thread retains the document-level fallback.
32
36
  *
33
37
  * @example
34
38
  * ```tsx
@@ -48,11 +52,29 @@ export const ThreadPrimitiveRoot = forwardRef<
48
52
  const aui = useAui();
49
53
  const rootRef = useRef<ThreadPrimitiveRoot.Element>(null);
50
54
  const composedRef = useComposedRefs(ref, rootRef);
55
+ const handleRootKeyDown: KeyboardEventHandler<ThreadPrimitiveRoot.Element> = (
56
+ event,
57
+ ) => {
58
+ if (event.key === "Escape" && rootRef.current) {
59
+ escapeEventThreadRoots.set(event.nativeEvent, rootRef.current);
60
+ }
61
+ props.onKeyDown?.(event);
62
+ };
51
63
 
52
64
  useEffect(() => {
53
65
  const handleKeyDown = (event: KeyboardEvent) => {
54
66
  if (event.key !== "Escape") return;
55
67
  if (event.defaultPrevented || aui.thread.source === null) return;
68
+ const eventThreadRoot =
69
+ escapeEventThreadRoots.get(event) ??
70
+ event
71
+ .composedPath()
72
+ .find(
73
+ (target) =>
74
+ target instanceof Element &&
75
+ target.hasAttribute("data-aui-thread-root"),
76
+ );
77
+ if (eventThreadRoot && eventThreadRoot !== rootRef.current) return;
56
78
  if (aui.thread.getState().speech == null) return;
57
79
  event.preventDefault();
58
80
  try {
@@ -77,7 +99,12 @@ export const ThreadPrimitiveRoot = forwardRef<
77
99
 
78
100
  return (
79
101
  <ThreadRootElementContext.Provider value={rootRef}>
80
- <Primitive.div {...props} ref={composedRef} />
102
+ <Primitive.div
103
+ {...props}
104
+ data-aui-thread-root=""
105
+ ref={composedRef}
106
+ onKeyDown={handleRootKeyDown}
107
+ />
81
108
  </ThreadRootElementContext.Provider>
82
109
  );
83
110
  });
@@ -40,6 +40,8 @@ export const ThreadListItemPrimitiveRoot = forwardRef<
40
40
  const direction = Direction.useDirection();
41
41
 
42
42
  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
43
+ if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey)
44
+ return;
43
45
  const forwardKey = direction === "rtl" ? "ArrowLeft" : "ArrowRight";
44
46
  const backKey = direction === "rtl" ? "ArrowRight" : "ArrowLeft";
45
47
  const trigger = triggerRef.current;
@@ -60,6 +60,36 @@ const menu = (): HTMLElement | null =>
60
60
  document.querySelector<HTMLElement>('[role="menu"]');
61
61
 
62
62
  describe("thread list keyboard navigation", () => {
63
+ it.each(["altKey", "ctrlKey", "metaKey", "shiftKey"])(
64
+ "leaves arrows modified with %s available to ancestor shortcuts",
65
+ (modifier) => {
66
+ const prevented: boolean[] = [];
67
+ const { container } = render(
68
+ <div onKeyDown={(event) => prevented.push(event.defaultPrevented)}>
69
+ <ThreadListPrimitiveRoot>
70
+ {[0, 1].map((i) => (
71
+ <ThreadListItemPrimitiveRoot key={i}>
72
+ <ThreadListItemPrimitiveTrigger>
73
+ item {i}
74
+ </ThreadListItemPrimitiveTrigger>
75
+ </ThreadListItemPrimitiveRoot>
76
+ ))}
77
+ </ThreadListPrimitiveRoot>
78
+ </div>,
79
+ );
80
+ const [first, second] = triggers(container);
81
+
82
+ first!.focus();
83
+ fireEvent.keyDown(first!, { key: "ArrowDown", [modifier]: true });
84
+ expect(document.activeElement).toBe(first);
85
+
86
+ second!.focus();
87
+ fireEvent.keyDown(second!, { key: "ArrowUp", [modifier]: true });
88
+ expect(document.activeElement).toBe(second);
89
+ expect(prevented).toEqual([false, false]);
90
+ },
91
+ );
92
+
63
93
  it("moves focus between items with the up/down arrows", () => {
64
94
  const { container } = render(
65
95
  <ThreadListPrimitiveRoot>
@@ -102,6 +132,41 @@ describe("thread list keyboard navigation", () => {
102
132
  expect(menu()).not.toBeNull();
103
133
  });
104
134
 
135
+ it.each(["altKey", "ctrlKey", "metaKey", "shiftKey"])(
136
+ "leaves the More menu closed on ArrowRight modified with %s",
137
+ (modifier) => {
138
+ const { more } = renderItem();
139
+
140
+ more.focus();
141
+ const unhandled = fireEvent.keyDown(more, {
142
+ key: "ArrowRight",
143
+ [modifier]: true,
144
+ });
145
+
146
+ expect(menu()).toBeNull();
147
+ expect(document.activeElement).toBe(more);
148
+ expect(unhandled).toBe(true);
149
+ },
150
+ );
151
+
152
+ it.each(["altKey", "ctrlKey", "metaKey", "shiftKey"])(
153
+ "leaves the More menu open on ArrowLeft modified with %s",
154
+ (modifier) => {
155
+ renderItem({ defaultOpen: true });
156
+ const content = menu()!;
157
+
158
+ content.focus();
159
+ const unhandled = fireEvent.keyDown(content, {
160
+ key: "ArrowLeft",
161
+ [modifier]: true,
162
+ });
163
+
164
+ expect(menu()).toBe(content);
165
+ expect(document.activeElement).toBe(content);
166
+ expect(unhandled).toBe(true);
167
+ },
168
+ );
169
+
105
170
  it.each(["ArrowLeft", "Escape"])(
106
171
  "closes the menu on %s and returns focus to the More button",
107
172
  (key) => {
@@ -54,6 +54,13 @@ export const ThreadListItemMorePrimitiveContent = forwardRef<
54
54
  {...props}
55
55
  onKeyDown={composeEventHandlers(props.onKeyDown, (event) => {
56
56
  if (!sharedFocusGroup || event.key !== closeKey) return;
57
+ if (
58
+ event.altKey ||
59
+ event.ctrlKey ||
60
+ event.metaKey ||
61
+ event.shiftKey
62
+ )
63
+ return;
57
64
  event.preventDefault();
58
65
  setOpen(false);
59
66
  focus?.moreRef.current?.focus();
@@ -46,6 +46,8 @@ export const ThreadListItemMorePrimitiveTrigger = forwardRef<
46
46
  {...rest}
47
47
  onKeyDown={composeEventHandlers(rest.onKeyDown, (event) => {
48
48
  if (!sharedFocusGroup || event.key !== openKey) return;
49
+ if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey)
50
+ return;
49
51
  event.preventDefault();
50
52
  setOpen(true);
51
53
  })}
@@ -5,7 +5,10 @@ import { type FC, useEffect } from "react";
5
5
  import { describe, expect, it } from "vitest";
6
6
  import type { AssistantRuntime } from "@assistant-ui/core";
7
7
  import { useRemoteThreadListRuntime } from "@assistant-ui/core/react";
8
- import { makeAdapter } from "./remote-thread-list-test-helpers";
8
+ import {
9
+ makeAdapter,
10
+ settleOutsideAct,
11
+ } from "./remote-thread-list-test-helpers";
9
12
  import { useLocalRuntime } from "../legacy-runtime/runtime-cores/local/useLocalRuntime";
10
13
  import { AssistantRuntimeProvider } from "../context";
11
14
  import type { ChatModelAdapter } from "../index";
@@ -74,15 +77,11 @@ describe("threads.reloadMainThread", () => {
74
77
  const capture = await renderThreadList(mounts);
75
78
  const runtime = capture.runtime!;
76
79
 
77
- await act(async () => {
78
- await runtime.threads.switchToThread("t-1");
79
- });
80
+ await settleOutsideAct(() => runtime.threads.switchToThread("t-1"));
80
81
  const beforeReload = await settle(mounts);
81
82
  expect(beforeReload).toBeGreaterThan(0);
82
83
 
83
- await act(async () => {
84
- await runtime.threads.reloadMainThread();
85
- });
84
+ await settleOutsideAct(() => runtime.threads.reloadMainThread());
86
85
 
87
86
  await waitFor(() => expect(mounts.count).toBeGreaterThan(beforeReload));
88
87
  });
@@ -92,18 +91,14 @@ describe("threads.reloadMainThread", () => {
92
91
  const capture = await renderThreadList(mounts);
93
92
  const runtime = capture.runtime!;
94
93
 
95
- await act(async () => {
96
- await runtime.threads.switchToThread("t-1");
97
- });
94
+ await settleOutsideAct(() => runtime.threads.switchToThread("t-1"));
98
95
 
99
96
  const seen: boolean[] = [];
100
97
  const unsubscribe = runtime.threads.subscribe(() => {
101
98
  seen.push(runtime.threads.getState().mainThreadId === "t-1");
102
99
  });
103
100
 
104
- await act(async () => {
105
- await runtime.threads.reloadMainThread();
106
- });
101
+ await settleOutsideAct(() => runtime.threads.reloadMainThread());
107
102
  unsubscribe();
108
103
 
109
104
  expect(seen.length).toBeGreaterThan(0);
@@ -1,3 +1,4 @@
1
+ import { act, waitFor } from "@testing-library/react";
1
2
  import { vi } from "vitest";
2
3
  import type { RemoteThreadListAdapter } from "../index";
3
4
 
@@ -31,3 +32,17 @@ export function makeAdapter(
31
32
  ...overrides,
32
33
  };
33
34
  }
35
+
36
+ // React 18's act holds renders until its callback settles, so a task that waits on a render starts inside act and settles outside it, where React can commit what the task waits on.
37
+ export async function settleOutsideAct<T>(task: () => Promise<T>): Promise<T> {
38
+ let pending!: Promise<T>;
39
+ await act(async () => {
40
+ pending = task();
41
+ pending.catch(() => {});
42
+ });
43
+ await waitFor(() => pending.then(settled, settled));
44
+ await act(async () => {});
45
+ return pending;
46
+ }
47
+
48
+ const settled = () => {};
@@ -5,7 +5,10 @@ import { type FC, useEffect, useReducer } from "react";
5
5
  import { describe, expect, it } from "vitest";
6
6
  import type { ThreadMessage } from "@assistant-ui/core";
7
7
  import { useRemoteThreadListRuntime } from "@assistant-ui/core/react";
8
- import { makeAdapter } from "./remote-thread-list-test-helpers";
8
+ import {
9
+ makeAdapter,
10
+ settleOutsideAct,
11
+ } from "./remote-thread-list-test-helpers";
9
12
  import { AssistantRuntimeProvider } from "../context";
10
13
  import * as ThreadListPrimitive from "../primitives/threadList";
11
14
  import {
@@ -172,9 +175,9 @@ describe("threadListItem.isRunning", () => {
172
175
  });
173
176
  await waitFor(() => expect(screen.getByTestId("item-t-1")).toBeTruthy());
174
177
 
175
- await act(async () => {
176
- await capture.runtime!.threads.switchToThread("t-1");
177
- });
178
+ await settleOutsideAct(() =>
179
+ capture.runtime!.threads.switchToThread("t-1"),
180
+ );
178
181
  await act(async () => {
179
182
  setRunning("t-1", true);
180
183
  });
@@ -182,9 +185,9 @@ describe("threadListItem.isRunning", () => {
182
185
  expect(screen.getByTestId("item-t-1").textContent).toBe("running"),
183
186
  );
184
187
 
185
- await act(async () => {
186
- await capture.runtime!.threads.switchToThread("t-2");
187
- });
188
+ await settleOutsideAct(() =>
189
+ capture.runtime!.threads.switchToThread("t-2"),
190
+ );
188
191
 
189
192
  expect(screen.getByTestId("item-t-1").textContent).toBe("running");
190
193
  expect(screen.getByTestId("item-t-2").textContent).toBe("idle");