@assistant-ui/react 0.15.17 → 0.15.19

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 (169) hide show
  1. package/README.md +8 -2
  2. package/dist/context/providers/ThreadViewportProvider.js +2 -2
  3. package/dist/context/providers/ThreadViewportProvider.js.map +1 -1
  4. package/dist/hooks/useToolCallElapsed.js.map +1 -1
  5. package/dist/index.d.ts +7 -4
  6. package/dist/index.js +5 -3
  7. package/dist/mcp-apps/McpAppRenderer.d.ts +30 -8
  8. package/dist/mcp-apps/McpAppRenderer.d.ts.map +1 -1
  9. package/dist/mcp-apps/McpAppRenderer.js +91 -47
  10. package/dist/mcp-apps/McpAppRenderer.js.map +1 -1
  11. package/dist/mcp-apps/app-frame.d.ts.map +1 -1
  12. package/dist/mcp-apps/app-frame.js +57 -33
  13. package/dist/mcp-apps/app-frame.js.map +1 -1
  14. package/dist/mcp-apps/bridge.js +9 -4
  15. package/dist/mcp-apps/bridge.js.map +1 -1
  16. package/dist/mcp-apps/index.d.ts +2 -2
  17. package/dist/primitives/assistantModal/AssistantModalRoot.d.ts.map +1 -1
  18. package/dist/primitives/assistantModal/AssistantModalRoot.js +47 -79
  19. package/dist/primitives/assistantModal/AssistantModalRoot.js.map +1 -1
  20. package/dist/primitives/composer/ComposerAttachmentDropzone.d.ts.map +1 -1
  21. package/dist/primitives/composer/ComposerAttachmentDropzone.js +61 -138
  22. package/dist/primitives/composer/ComposerAttachmentDropzone.js.map +1 -1
  23. package/dist/primitives/composer/ComposerInput.js +3 -8
  24. package/dist/primitives/composer/ComposerInput.js.map +1 -1
  25. package/dist/primitives/composer/ComposerInputPluginContext.d.ts +1 -2
  26. package/dist/primitives/composer/ComposerInputPluginContext.d.ts.map +1 -1
  27. package/dist/primitives/composer/ComposerInputPluginContext.js +1 -6
  28. package/dist/primitives/composer/ComposerInputPluginContext.js.map +1 -1
  29. package/dist/primitives/composer/trigger/TriggerPopoverAction.js +46 -15
  30. package/dist/primitives/composer/trigger/TriggerPopoverAction.js.map +1 -1
  31. package/dist/primitives/composer/trigger/TriggerPopoverDirective.js +40 -10
  32. package/dist/primitives/composer/trigger/TriggerPopoverDirective.js.map +1 -1
  33. package/dist/primitives/composer/trigger/matchesTriggerItemQuery.d.ts +6 -0
  34. package/dist/primitives/composer/trigger/matchesTriggerItemQuery.d.ts.map +1 -0
  35. package/dist/primitives/composer/trigger/matchesTriggerItemQuery.js +9 -0
  36. package/dist/primitives/composer/trigger/matchesTriggerItemQuery.js.map +1 -0
  37. package/dist/primitives/composer/trigger/triggerKeyboardResource.d.ts.map +1 -1
  38. package/dist/primitives/composer/trigger/triggerKeyboardResource.js +53 -61
  39. package/dist/primitives/composer/trigger/triggerKeyboardResource.js.map +1 -1
  40. package/dist/primitives/composer/trigger/triggerNavigationResource.d.ts.map +1 -1
  41. package/dist/primitives/composer/trigger/triggerNavigationResource.js +129 -141
  42. package/dist/primitives/composer/trigger/triggerNavigationResource.js.map +1 -1
  43. package/dist/primitives/message/MessageParts.js +2 -14
  44. package/dist/primitives/message/MessageParts.js.map +1 -1
  45. package/dist/primitives/message/MessagePartsGrouped.d.ts +3 -3
  46. package/dist/primitives/message/MessagePartsGrouped.js +2 -2
  47. package/dist/primitives/message/MessagePartsGrouped.js.map +1 -1
  48. package/dist/primitives/messagePart/MessagePartText.d.ts +2 -0
  49. package/dist/primitives/messagePart/MessagePartText.d.ts.map +1 -1
  50. package/dist/primitives/messagePart/MessagePartText.js +13 -36
  51. package/dist/primitives/messagePart/MessagePartText.js.map +1 -1
  52. package/dist/primitives/reasoning/useScrollLock.js +3 -1
  53. package/dist/primitives/reasoning/useScrollLock.js.map +1 -1
  54. package/dist/primitives/thread/useThreadViewportAutoScroll.d.ts.map +1 -1
  55. package/dist/primitives/thread/useThreadViewportAutoScroll.js +3 -3
  56. package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
  57. package/dist/sandbox-host/SandboxHost.d.ts.map +1 -1
  58. package/dist/sandbox-host/SandboxHost.js +36 -9
  59. package/dist/sandbox-host/SandboxHost.js.map +1 -1
  60. package/dist/unstable/useLiveCompletionAdapter.js +24 -2
  61. package/dist/unstable/useLiveCompletionAdapter.js.map +1 -1
  62. package/dist/unstable/useMentionAdapter.d.ts +9 -3
  63. package/dist/unstable/useMentionAdapter.d.ts.map +1 -1
  64. package/dist/unstable/useMentionAdapter.js +50 -43
  65. package/dist/unstable/useMentionAdapter.js.map +1 -1
  66. package/dist/unstable/useMessageStallDetection.d.ts +4 -5
  67. package/dist/unstable/useMessageStallDetection.d.ts.map +1 -1
  68. package/dist/unstable/useMessageStallDetection.js +16 -15
  69. package/dist/unstable/useMessageStallDetection.js.map +1 -1
  70. package/dist/unstable/useModelContextSnapshot.d.ts +34 -0
  71. package/dist/unstable/useModelContextSnapshot.d.ts.map +1 -0
  72. package/dist/unstable/useModelContextSnapshot.js +61 -0
  73. package/dist/unstable/useModelContextSnapshot.js.map +1 -0
  74. package/dist/unstable/useSlashCommandAdapter.d.ts.map +1 -1
  75. package/dist/unstable/useSlashCommandAdapter.js +2 -8
  76. package/dist/unstable/useSlashCommandAdapter.js.map +1 -1
  77. package/dist/unstable/webmcp/WebMcpRegistrationResource.d.ts +14 -0
  78. package/dist/unstable/webmcp/WebMcpRegistrationResource.d.ts.map +1 -0
  79. package/dist/unstable/webmcp/WebMcpRegistrationResource.js +85 -0
  80. package/dist/unstable/webmcp/WebMcpRegistrationResource.js.map +1 -0
  81. package/dist/unstable/webmcp/convertTools.d.ts +21 -0
  82. package/dist/unstable/webmcp/convertTools.d.ts.map +1 -0
  83. package/dist/unstable/webmcp/convertTools.js +161 -0
  84. package/dist/unstable/webmcp/convertTools.js.map +1 -0
  85. package/dist/unstable/webmcp/useWebMcpProvider.d.ts +21 -0
  86. package/dist/unstable/webmcp/useWebMcpProvider.d.ts.map +1 -0
  87. package/dist/unstable/webmcp/useWebMcpProvider.js +106 -0
  88. package/dist/unstable/webmcp/useWebMcpProvider.js.map +1 -0
  89. package/dist/unstable/webmcp/webmcp-host.d.ts +39 -0
  90. package/dist/unstable/webmcp/webmcp-host.d.ts.map +1 -0
  91. package/dist/unstable/webmcp/webmcp-host.js +29 -0
  92. package/dist/unstable/webmcp/webmcp-host.js.map +1 -0
  93. package/dist/utils/Primitive.d.ts +10 -2
  94. package/dist/utils/Primitive.d.ts.map +1 -1
  95. package/dist/utils/Primitive.js +25 -6
  96. package/dist/utils/Primitive.js.map +1 -1
  97. package/dist/utils/getSelectionMessageId.d.ts.map +1 -1
  98. package/dist/utils/getSelectionMessageId.js +17 -3
  99. package/dist/utils/getSelectionMessageId.js.map +1 -1
  100. package/package.json +13 -13
  101. package/src/context/providers/ThreadViewportProvider.tsx +2 -2
  102. package/src/hooks/useToolCallElapsed.ts +3 -0
  103. package/src/index.ts +14 -1
  104. package/src/mcp-apps/McpAppRenderer.test.tsx +253 -5
  105. package/src/mcp-apps/McpAppRenderer.tsx +147 -52
  106. package/src/mcp-apps/app-frame.test.tsx +437 -1
  107. package/src/mcp-apps/app-frame.tsx +76 -21
  108. package/src/mcp-apps/bridge.test.ts +98 -0
  109. package/src/mcp-apps/bridge.ts +14 -3
  110. package/src/mcp-apps/index.ts +5 -1
  111. package/src/primitives/assistantModal/AssistantModalRoot.test.tsx +123 -0
  112. package/src/primitives/assistantModal/AssistantModalRoot.tsx +11 -31
  113. package/src/primitives/assistantModal/AssistantModalTrigger.test.tsx +1 -16
  114. package/src/primitives/composer/ComposerAttachmentDropzone.test.tsx +43 -0
  115. package/src/primitives/composer/ComposerAttachmentDropzone.tsx +2 -10
  116. package/src/primitives/composer/ComposerInput.test.tsx +22 -0
  117. package/src/primitives/composer/ComposerInput.tsx +5 -9
  118. package/src/primitives/composer/ComposerInputPluginContext.tsx +0 -10
  119. package/src/primitives/composer/trigger/TriggerPopoverAction.tsx +4 -2
  120. package/src/primitives/composer/trigger/TriggerPopoverBehavior.test.tsx +109 -0
  121. package/src/primitives/composer/trigger/TriggerPopoverDirective.tsx +4 -2
  122. package/src/primitives/composer/trigger/matchesTriggerItemQuery.test.ts +52 -0
  123. package/src/primitives/composer/trigger/matchesTriggerItemQuery.ts +13 -0
  124. package/src/primitives/composer/trigger/triggerKeyboardResource.ts +13 -7
  125. package/src/primitives/composer/trigger/triggerNavigationResource.ts +8 -13
  126. package/src/primitives/message/MessageParts.tsx +1 -21
  127. package/src/primitives/message/MessagePartsGrouped.test.tsx +93 -0
  128. package/src/primitives/message/MessagePartsGrouped.tsx +6 -6
  129. package/src/primitives/messagePart/MessagePartText.test.tsx +69 -0
  130. package/src/primitives/messagePart/MessagePartText.tsx +22 -9
  131. package/src/primitives/reasoning/useScrollLock.test.tsx +129 -0
  132. package/src/primitives/reasoning/useScrollLock.ts +15 -1
  133. package/src/primitives/thread/useThreadViewportAutoScroll.test.tsx +38 -1
  134. package/src/primitives/thread/useThreadViewportAutoScroll.ts +7 -6
  135. package/src/sandbox-host/SandboxHost.test.tsx +160 -2
  136. package/src/sandbox-host/SandboxHost.tsx +50 -10
  137. package/src/tests/MessageParts.rendererOptions.test.tsx +117 -0
  138. package/src/tests/toolCallTiming.test.tsx +55 -1
  139. package/src/unstable/useComposerInputHistory.test.tsx +2 -1
  140. package/src/unstable/useLiveCompletionAdapter.test.tsx +202 -1
  141. package/src/unstable/useLiveCompletionAdapter.ts +38 -8
  142. package/src/unstable/useMentionAdapter.test.tsx +383 -0
  143. package/src/unstable/useMentionAdapter.ts +94 -57
  144. package/src/unstable/useMessageStallDetection.ts +26 -19
  145. package/src/unstable/useModelContextSnapshot.test.tsx +132 -0
  146. package/src/unstable/useModelContextSnapshot.ts +67 -0
  147. package/src/unstable/useSlashCommandAdapter.ts +2 -9
  148. package/src/unstable/webmcp/WebMcpRegistrationResource.ts +92 -0
  149. package/src/unstable/webmcp/__tests__/webmcp.fake.tsx +168 -0
  150. package/src/unstable/webmcp/convertTools.test.ts +553 -0
  151. package/src/unstable/webmcp/convertTools.ts +260 -0
  152. package/src/unstable/webmcp/useWebMcpProvider.test.tsx +466 -0
  153. package/src/unstable/webmcp/useWebMcpProvider.ts +148 -0
  154. package/src/unstable/webmcp/webmcp-host.test.ts +142 -0
  155. package/src/unstable/webmcp/webmcp-host.ts +83 -0
  156. package/src/utils/Primitive.test.tsx +46 -1
  157. package/src/utils/Primitive.tsx +37 -7
  158. package/src/utils/getSelectionMessageId.test.ts +176 -1
  159. package/src/utils/getSelectionMessageId.ts +21 -3
  160. package/dist/utils/invokeCallbackSafely.d.ts +0 -5
  161. package/dist/utils/invokeCallbackSafely.d.ts.map +0 -1
  162. package/dist/utils/invokeCallbackSafely.js +0 -15
  163. package/dist/utils/invokeCallbackSafely.js.map +0 -1
  164. package/dist/utils/useToolArgsFieldStatus.d.ts +0 -19
  165. package/dist/utils/useToolArgsFieldStatus.d.ts.map +0 -1
  166. package/dist/utils/useToolArgsFieldStatus.js +0 -23
  167. package/dist/utils/useToolArgsFieldStatus.js.map +0 -1
  168. package/src/utils/invokeCallbackSafely.ts +0 -11
  169. package/src/utils/useToolArgsFieldStatus.ts +0 -18
@@ -0,0 +1,383 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { act, renderHook } from "@testing-library/react";
4
+ import {
5
+ createTapRoot,
6
+ flushTapSync,
7
+ resource,
8
+ useResource,
9
+ } from "@assistant-ui/tap";
10
+ import type { Unstable_TriggerAdapter } from "@assistant-ui/core";
11
+ import { useState } from "react";
12
+ import { beforeEach, describe, expect, it, vi } from "vitest";
13
+ import { TriggerNavigationResource } from "../primitives/composer/trigger/triggerNavigationResource";
14
+ import {
15
+ unstable_useMentionAdapter,
16
+ type Unstable_UseMentionAdapterOptions,
17
+ } from "./useMentionAdapter";
18
+
19
+ const runtime = vi.hoisted(() => {
20
+ const state = {
21
+ tools: {} as Record<string, { description?: string }>,
22
+ };
23
+ const listeners = new Map<string, Set<() => void>>();
24
+ const events: string[] = [];
25
+ return {
26
+ state,
27
+ listeners,
28
+ events,
29
+ client: {
30
+ thread: {
31
+ getModelContext: () => ({ tools: state.tools }),
32
+ },
33
+ on: (
34
+ selector: string | { scope: string; event: string },
35
+ listener: () => void,
36
+ ) => {
37
+ const event = typeof selector === "string" ? selector : selector.event;
38
+ events.push(event);
39
+ const eventListeners = listeners.get(event) ?? new Set();
40
+ eventListeners.add(listener);
41
+ listeners.set(event, eventListeners);
42
+ return () => eventListeners.delete(listener);
43
+ },
44
+ },
45
+ emit: (event: string) =>
46
+ listeners.get(event)?.forEach((listener) => listener()),
47
+ };
48
+ });
49
+
50
+ vi.mock("@assistant-ui/store", async (importOriginal) => ({
51
+ ...(await importOriginal<typeof import("@assistant-ui/store")>()),
52
+ useAui: () => runtime.client,
53
+ }));
54
+
55
+ const TestNavigationResource = resource(function useTestNavigationResource(
56
+ initialAdapter: Unstable_TriggerAdapter,
57
+ ) {
58
+ const [adapter, setAdapter] = useState(initialAdapter);
59
+ const navigation = useResource(
60
+ TriggerNavigationResource({ adapter, query: "", open: true }),
61
+ );
62
+ return { navigation, setAdapter };
63
+ });
64
+
65
+ describe("unstable_useMentionAdapter", () => {
66
+ beforeEach(() => {
67
+ runtime.state.tools = {};
68
+ runtime.listeners.clear();
69
+ runtime.events.length = 0;
70
+ });
71
+
72
+ it("ignores flat items when categories is explicitly empty", () => {
73
+ const { result } = renderHook(() =>
74
+ unstable_useMentionAdapter({
75
+ items: [{ id: "alice", type: "person", label: "Alice" }],
76
+ categories: [],
77
+ }),
78
+ );
79
+
80
+ expect(result.current.adapter.search?.("")).toEqual([]);
81
+ });
82
+
83
+ it("does not expose flat items when the last category is removed", () => {
84
+ const items = [{ id: "alice", type: "person", label: "Alice" }];
85
+ const { result, rerender } = renderHook(
86
+ (options: Unstable_UseMentionAdapterOptions) =>
87
+ unstable_useMentionAdapter(options),
88
+ {
89
+ initialProps: {
90
+ items,
91
+ categories: [
92
+ {
93
+ id: "people",
94
+ label: "People",
95
+ items: [{ id: "bob", type: "person", label: "Bob" }],
96
+ },
97
+ ],
98
+ },
99
+ },
100
+ );
101
+
102
+ expect(result.current.adapter.search?.("")).toEqual([
103
+ { id: "bob", type: "person", label: "Bob" },
104
+ ]);
105
+
106
+ rerender({ items, categories: [] });
107
+ expect(result.current.adapter.search?.("")).toEqual([]);
108
+
109
+ rerender({ items });
110
+ expect(result.current.adapter.search?.("")).toEqual([
111
+ { id: "alice", type: "person", label: "Alice" },
112
+ ]);
113
+ });
114
+
115
+ it("keeps categorized model-context tools current", () => {
116
+ runtime.state.tools = {
117
+ searchDocs: { description: "Search documentation" },
118
+ };
119
+
120
+ const { result } = renderHook(() =>
121
+ unstable_useMentionAdapter({
122
+ categories: [{ id: "people", label: "People", items: [] }],
123
+ includeModelContextTools: true,
124
+ }),
125
+ );
126
+ const initialAdapter = result.current.adapter;
127
+ const navigationRoot = createTapRoot(function NavigationRoot() {
128
+ return useResource(TestNavigationResource(initialAdapter));
129
+ });
130
+
131
+ expect(navigationRoot.getValue().navigation.categories).toEqual([
132
+ { id: "people", label: "People" },
133
+ { id: "tools", label: "Tools" },
134
+ ]);
135
+ expect(runtime.events).toContain("thread.modelContextUpdate");
136
+
137
+ act(() =>
138
+ flushTapSync(() =>
139
+ navigationRoot.getValue().navigation.selectCategory("tools"),
140
+ ),
141
+ );
142
+
143
+ expect(navigationRoot.getValue().navigation.items).toEqual([
144
+ {
145
+ id: "searchDocs",
146
+ type: "tool",
147
+ label: "searchDocs",
148
+ description: "Search documentation",
149
+ },
150
+ ]);
151
+
152
+ act(() => {
153
+ runtime.state.tools = {
154
+ createIssue: { description: "Create an issue" },
155
+ };
156
+ runtime.emit("thread.modelContextUpdate");
157
+ });
158
+
159
+ expect(result.current.adapter).not.toBe(initialAdapter);
160
+ act(() =>
161
+ flushTapSync(() =>
162
+ navigationRoot.getValue().setAdapter(result.current.adapter),
163
+ ),
164
+ );
165
+ expect(navigationRoot.getValue().navigation.items).toEqual([
166
+ {
167
+ id: "createIssue",
168
+ type: "tool",
169
+ label: "createIssue",
170
+ description: "Create an issue",
171
+ },
172
+ ]);
173
+ act(() => {
174
+ runtime.state.tools = {};
175
+ runtime.emit("thread.modelContextUpdate");
176
+ });
177
+ act(() =>
178
+ flushTapSync(() =>
179
+ navigationRoot.getValue().setAdapter(result.current.adapter),
180
+ ),
181
+ );
182
+
183
+ expect(navigationRoot.getValue().navigation.categories).toEqual([
184
+ { id: "people", label: "People" },
185
+ ]);
186
+ expect(navigationRoot.getValue().navigation.items).toEqual([]);
187
+ navigationRoot.unmount();
188
+ });
189
+
190
+ it("keeps flat model-context tools current while navigation is open", () => {
191
+ runtime.state.tools = {
192
+ searchDocs: { description: "Search documentation" },
193
+ };
194
+
195
+ const { result } = renderHook(() => unstable_useMentionAdapter());
196
+ const initialAdapter = result.current.adapter;
197
+ const navigationRoot = createTapRoot(function NavigationRoot() {
198
+ return useResource(TestNavigationResource(initialAdapter));
199
+ });
200
+
201
+ expect(runtime.events).toContain("thread.modelContextUpdate");
202
+
203
+ expect(navigationRoot.getValue().navigation.items).toEqual([
204
+ {
205
+ id: "searchDocs",
206
+ type: "tool",
207
+ label: "searchDocs",
208
+ description: "Search documentation",
209
+ },
210
+ ]);
211
+
212
+ act(() => {
213
+ runtime.state.tools = {
214
+ createIssue: { description: "Create an issue" },
215
+ };
216
+ runtime.emit("thread.modelContextUpdate");
217
+ });
218
+
219
+ expect(result.current.adapter).not.toBe(initialAdapter);
220
+ act(() =>
221
+ flushTapSync(() =>
222
+ navigationRoot.getValue().setAdapter(result.current.adapter),
223
+ ),
224
+ );
225
+ expect(navigationRoot.getValue().navigation.items).toEqual([
226
+ {
227
+ id: "createIssue",
228
+ type: "tool",
229
+ label: "createIssue",
230
+ description: "Create an issue",
231
+ },
232
+ ]);
233
+ navigationRoot.unmount();
234
+ });
235
+
236
+ it("refreshes tool mentions when the selected thread changes", () => {
237
+ runtime.state.tools = {
238
+ searchDocs: { description: "Search documentation" },
239
+ };
240
+
241
+ const { result } = renderHook(() => unstable_useMentionAdapter());
242
+ const initialAdapter = result.current.adapter;
243
+
244
+ expect(runtime.events).toContain("threads.selectionChanged");
245
+
246
+ act(() => {
247
+ runtime.state.tools = {
248
+ createIssue: { description: "Create an issue" },
249
+ };
250
+ runtime.emit("threads.selectionChanged");
251
+ });
252
+
253
+ expect(result.current.adapter).not.toBe(initialAdapter);
254
+ expect(result.current.adapter.search?.("")).toEqual([
255
+ {
256
+ id: "createIssue",
257
+ type: "tool",
258
+ label: "createIssue",
259
+ description: "Create an issue",
260
+ },
261
+ ]);
262
+ });
263
+
264
+ it("keeps adapter identity when tool mentions are unchanged", () => {
265
+ runtime.state.tools = {
266
+ searchDocs: { description: "Search documentation" },
267
+ };
268
+
269
+ const { result } = renderHook(() => unstable_useMentionAdapter());
270
+ const initialAdapter = result.current.adapter;
271
+
272
+ act(() => {
273
+ runtime.state.tools = {
274
+ searchDocs: { description: "Search documentation" },
275
+ };
276
+ runtime.emit("thread.modelContextUpdate");
277
+ });
278
+
279
+ expect(result.current.adapter).toBe(initialAdapter);
280
+ });
281
+
282
+ it("observes a description mutated in place on a stable tool object", () => {
283
+ const tool = { description: "Search documentation" };
284
+ runtime.state.tools = { searchDocs: tool };
285
+
286
+ const { result } = renderHook(() => unstable_useMentionAdapter());
287
+ const initialAdapter = result.current.adapter;
288
+
289
+ act(() => {
290
+ tool.description = "Search the docs site";
291
+ runtime.emit("thread.modelContextUpdate");
292
+ });
293
+
294
+ expect(result.current.adapter).not.toBe(initialAdapter);
295
+ expect(result.current.adapter.search?.("")).toEqual([
296
+ {
297
+ id: "searchDocs",
298
+ type: "tool",
299
+ label: "searchDocs",
300
+ description: "Search the docs site",
301
+ },
302
+ ]);
303
+ });
304
+
305
+ it("uses an explicit tool category without custom categories", () => {
306
+ runtime.state.tools = {
307
+ searchDocs: { description: "Search documentation" },
308
+ };
309
+
310
+ const { result } = renderHook(() =>
311
+ unstable_useMentionAdapter({
312
+ includeModelContextTools: {
313
+ category: { id: "actions", label: "Actions" },
314
+ },
315
+ }),
316
+ );
317
+
318
+ expect(result.current.adapter.categories()).toEqual([
319
+ { id: "actions", label: "Actions" },
320
+ ]);
321
+ expect(result.current.adapter.categoryItems("actions")).toEqual([
322
+ {
323
+ id: "searchDocs",
324
+ type: "tool",
325
+ label: "searchDocs",
326
+ description: "Search documentation",
327
+ },
328
+ ]);
329
+ });
330
+
331
+ it("keeps an async explicit item list flat when a tool category is configured", () => {
332
+ runtime.state.tools = {
333
+ searchDocs: { description: "Search documentation" },
334
+ createIssue: { description: "Create an issue" },
335
+ };
336
+
337
+ const { result, rerender } = renderHook(
338
+ ({
339
+ items,
340
+ }: {
341
+ items: readonly {
342
+ id: string;
343
+ type: string;
344
+ label: string;
345
+ }[];
346
+ }) =>
347
+ unstable_useMentionAdapter({
348
+ items,
349
+ includeModelContextTools: {
350
+ category: { id: "actions", label: "Actions" },
351
+ },
352
+ }),
353
+ { initialProps: { items: [] } },
354
+ );
355
+
356
+ expect(result.current.adapter.categories()).toEqual([]);
357
+
358
+ rerender({
359
+ items: [
360
+ {
361
+ id: "searchDocs",
362
+ type: "person",
363
+ label: "Documentation owner",
364
+ },
365
+ ],
366
+ });
367
+
368
+ expect(result.current.adapter.categories()).toEqual([]);
369
+ expect(result.current.adapter.search?.("")).toEqual([
370
+ {
371
+ id: "searchDocs",
372
+ type: "person",
373
+ label: "Documentation owner",
374
+ },
375
+ {
376
+ id: "createIssue",
377
+ type: "tool",
378
+ label: "createIssue",
379
+ description: "Create an issue",
380
+ },
381
+ ]);
382
+ });
383
+ });
@@ -10,6 +10,12 @@ import type {
10
10
  } from "@assistant-ui/core";
11
11
  import { unstable_defaultDirectiveFormatter } from "@assistant-ui/core";
12
12
  import type { ReadonlyJSONObject } from "assistant-stream/utils";
13
+ import { matchesTriggerItemQuery } from "../primitives/composer/trigger/matchesTriggerItemQuery";
14
+ import {
15
+ shallowEqualRecords,
16
+ useModelContextSnapshot,
17
+ type ModelContextSnapshotSource,
18
+ } from "./useModelContextSnapshot";
13
19
 
14
20
  /** Icon component shape consumed by `ComposerTriggerPopover`'s `iconMap`. */
15
21
  export type Unstable_IconComponent = FC<{ className?: string }>;
@@ -31,7 +37,10 @@ export type Unstable_MentionCategory = {
31
37
  };
32
38
 
33
39
  export type Unstable_ModelContextToolsOptions = {
34
- /** Wrap tools in a dedicated category (drill-down mode). */
40
+ /**
41
+ * Wrap tools in a dedicated category. Selects drill-down mode on its own
42
+ * when `categories` is unset and `items` is omitted.
43
+ */
35
44
  readonly category?: { readonly id: string; readonly label: string };
36
45
  /** Format tool name for display. */
37
46
  readonly formatLabel?: (toolName: string) => string;
@@ -40,7 +49,10 @@ export type Unstable_ModelContextToolsOptions = {
40
49
  };
41
50
 
42
51
  export type Unstable_UseMentionAdapterOptions = {
43
- /** Flat mention list. Ignored when `categories` is set. */
52
+ /**
53
+ * Flat mention list. Ignored when `categories` is set, and keeps the
54
+ * adapter flat when a tool `category` is configured.
55
+ */
44
56
  readonly items?: readonly Unstable_Mention[];
45
57
  /** Categorized mentions for drill-down navigation. */
46
58
  readonly categories?: readonly Unstable_MentionCategory[];
@@ -49,7 +61,7 @@ export type Unstable_UseMentionAdapterOptions = {
49
61
  * - `false`: exclude.
50
62
  * - `true`: include (default when no `items`/`categories`; as a category
51
63
  * if `categories` is set, flat otherwise).
52
- * - object: explicit config.
64
+ * - object: explicit config; `category` also selects drill-down mode.
53
65
  *
54
66
  * Omitted → defaults to `true` iff neither `items` nor `categories`.
55
67
  */
@@ -71,6 +83,42 @@ export type Unstable_MentionDirective = {
71
83
  readonly onInserted?: ((item: Unstable_TriggerItem) => void) | undefined;
72
84
  };
73
85
 
86
+ const EMPTY_TOOL_MENTIONS: Readonly<Record<string, string | undefined>> =
87
+ Object.freeze({});
88
+
89
+ // The description is copied out rather than reached through the tool, so a
90
+ // provider that returns a stable tool object and edits it in place is observed.
91
+ const toolMentionSource: ModelContextSnapshotSource<
92
+ Readonly<Record<string, string | undefined>>
93
+ > = {
94
+ empty: EMPTY_TOOL_MENTIONS,
95
+ read: (aui) => {
96
+ const tools = aui.thread.getModelContext().tools;
97
+ if (!tools) return EMPTY_TOOL_MENTIONS;
98
+ const mentions = Object.create(null) as Record<string, string | undefined>;
99
+ for (const [name, tool] of Object.entries(tools)) {
100
+ mentions[name] = tool.description;
101
+ }
102
+ return mentions;
103
+ },
104
+ subscribe: (aui, onChange) => {
105
+ const unsubscribeContext = aui.on("thread.modelContextUpdate", onChange);
106
+ // Rebinding the thread event subject to a new thread does not replay it,
107
+ // so a switch between threads carrying different providers is its own
108
+ // refresh trigger. Subscribed globally because a composer can render
109
+ // without a thread list scope.
110
+ const unsubscribeSelection = aui.on(
111
+ { scope: "*", event: "threads.selectionChanged" },
112
+ onChange,
113
+ );
114
+ return () => {
115
+ unsubscribeContext();
116
+ unsubscribeSelection();
117
+ };
118
+ },
119
+ isEqual: shallowEqualRecords,
120
+ };
121
+
74
122
  /**
75
123
  * @deprecated Under active development and might change without notice.
76
124
  *
@@ -103,50 +151,48 @@ export function unstable_useMentionAdapter(
103
151
  const wantsTools = includeTools !== false;
104
152
  const formatter = options?.formatter;
105
153
  const onInserted = options?.onInserted;
154
+ const isCategorized =
155
+ categories !== undefined ||
156
+ (toolsConfig?.category !== undefined && items === undefined);
157
+ const toolMentions = useModelContextSnapshot(
158
+ aui,
159
+ wantsTools,
160
+ toolMentionSource,
161
+ );
106
162
 
107
163
  const adapter = useMemo<Unstable_TriggerAdapter>(() => {
108
- const getModelContextTools = (): Unstable_TriggerItem[] => {
109
- if (!wantsTools) return [];
110
- const ctx = aui.thread.getModelContext();
111
- const tools = ctx.tools;
112
- if (!tools) return [];
113
- const formatLabel = toolsConfig?.formatLabel;
114
- const defaultIcon = toolsConfig?.icon;
115
- return Object.entries(tools).map(([name, tool]) =>
116
- toTriggerItem({
117
- id: name,
118
- type: "tool",
119
- label: formatLabel ? formatLabel(name) : name,
120
- description: tool.description ?? undefined,
121
- icon: defaultIcon,
122
- }),
123
- );
124
- };
164
+ const formatLabel = toolsConfig?.formatLabel;
165
+ const defaultIcon = toolsConfig?.icon;
166
+ const toolItems = wantsTools
167
+ ? Object.entries(toolMentions).map(([name, description]) =>
168
+ toTriggerItem({
169
+ id: name,
170
+ type: "tool",
171
+ label: formatLabel ? formatLabel(name) : name,
172
+ description,
173
+ icon: defaultIcon,
174
+ }),
175
+ )
176
+ : [];
125
177
 
126
178
  // Categorized: drill-down mode
127
- if (categories && categories.length > 0) {
128
- const groups = categories.map((cat) => ({
179
+ if (isCategorized) {
180
+ const groups = (categories ?? []).map((cat) => ({
129
181
  id: cat.id,
130
182
  label: cat.label,
131
183
  items: cat.items.map(toTriggerItem),
132
184
  }));
133
-
134
- let toolCategory: {
135
- id: string;
136
- label: string;
137
- items: Unstable_TriggerItem[];
138
- } | null = null;
139
- if (wantsTools) {
140
- const toolItems = getModelContextTools();
141
- if (toolItems.length > 0) {
142
- toolCategory = {
143
- id: toolsConfig?.category?.id ?? "tools",
144
- label: toolsConfig?.category?.label ?? "Tools",
145
- items: toolItems,
146
- };
147
- }
148
- }
149
- const allGroups = toolCategory ? [...groups, toolCategory] : groups;
185
+ const allGroups =
186
+ toolItems.length > 0
187
+ ? [
188
+ ...groups,
189
+ {
190
+ id: toolsConfig?.category?.id ?? "tools",
191
+ label: toolsConfig?.category?.label ?? "Tools",
192
+ items: toolItems,
193
+ },
194
+ ]
195
+ : groups;
150
196
 
151
197
  return {
152
198
  categories: () => allGroups.map(({ id, label }) => ({ id, label })),
@@ -155,30 +201,29 @@ export function unstable_useMentionAdapter(
155
201
  const lower = query.toLowerCase();
156
202
  return allGroups
157
203
  .flatMap((g) => g.items)
158
- .filter((item) => matchesQuery(item, lower));
204
+ .filter((item) => matchesTriggerItemQuery(item, lower));
159
205
  },
160
206
  };
161
207
  }
162
208
 
163
209
  // Flat: items + (optionally) tools, all in one search pool
164
210
  const flatItems = (items ?? []).map(toTriggerItem);
165
- const getFlatPool = (): Unstable_TriggerItem[] => {
166
- if (!wantsTools) return flatItems;
167
- const toolItems = getModelContextTools();
168
- // Dedupe by id — explicit items win.
169
- const seen = new Set(flatItems.map((i) => i.id));
170
- return [...flatItems, ...toolItems.filter((t) => !seen.has(t.id))];
171
- };
211
+ // Dedupe by id explicit items win.
212
+ const seen = new Set(flatItems.map((i) => i.id));
213
+ const flatPool = [
214
+ ...flatItems,
215
+ ...toolItems.filter((t) => !seen.has(t.id)),
216
+ ];
172
217
 
173
218
  return {
174
219
  categories: (): readonly Unstable_TriggerCategory[] => [],
175
220
  categoryItems: () => [],
176
221
  search: (query) => {
177
222
  const lower = query.toLowerCase();
178
- return getFlatPool().filter((item) => matchesQuery(item, lower));
223
+ return flatPool.filter((item) => matchesTriggerItemQuery(item, lower));
179
224
  },
180
225
  };
181
- }, [aui, items, categories, wantsTools, toolsConfig]);
226
+ }, [items, categories, wantsTools, toolsConfig, isCategorized, toolMentions]);
182
227
 
183
228
  const directive = useMemo<Unstable_MentionDirective>(
184
229
  () => ({
@@ -207,11 +252,3 @@ function toTriggerItem(m: Unstable_Mention): Unstable_TriggerItem {
207
252
  ...(metadata !== undefined ? { metadata } : {}),
208
253
  };
209
254
  }
210
-
211
- function matchesQuery(item: Unstable_TriggerItem, lower: string): boolean {
212
- if (!lower) return true;
213
- if (item.id.toLowerCase().includes(lower)) return true;
214
- if (item.label.toLowerCase().includes(lower)) return true;
215
- if (item.description?.toLowerCase().includes(lower)) return true;
216
- return false;
217
- }
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { useEffect, useRef, useState } from "react";
4
4
  import { useAuiState } from "@assistant-ui/store";
5
+ import { useShallowSelector } from "@assistant-ui/store/internal";
5
6
 
6
7
  export type Unstable_MessageStallDetectionOptions = {
7
8
  /**
@@ -23,11 +24,10 @@ export type Unstable_MessageStallDetection = {
23
24
  * @deprecated Under active development and might change without notice.
24
25
  *
25
26
  * Detects mid-run output stalls on the current message: while the message is
26
- * running, watches a fingerprint of its content (part count plus text,
27
- * argument, and result sizes) and reports a stall once the fingerprint stops
28
- * changing for `thresholdMs`. Useful for re-surfacing a "still working"
29
- * indicator during tool think-time or provider stalls, after the first
30
- * tokens have already streamed.
27
+ * running, watches its text, reasoning, and tool-argument values plus tool-result
28
+ * availability and reports a stall once they stop changing for `thresholdMs`.
29
+ * Useful for re-surfacing a "still working" indicator during tool think-time or
30
+ * provider stalls, after the first tokens have already streamed.
31
31
  *
32
32
  * Must be used inside a message scope.
33
33
  */
@@ -36,20 +36,27 @@ export function unstable_useMessageStallDetection(
36
36
  ): Unstable_MessageStallDetection {
37
37
  const thresholdMs = options?.thresholdMs ?? 2000;
38
38
 
39
- const fingerprint = useAuiState((s) => {
40
- if (s.message.status?.type !== "running") return undefined;
41
- let size = 0;
42
- for (const part of s.message.content) {
43
- if (part.type === "text" || part.type === "reasoning") {
44
- size += part.text.length;
45
- } else if (part.type === "tool-call") {
46
- size += part.argsText.length + (part.result !== undefined ? 1 : 0);
39
+ const activity = useAuiState(
40
+ useShallowSelector((s) => {
41
+ const running = s.message.status?.type === "running";
42
+ if (!running) return [false];
43
+
44
+ const values: unknown[] = [true, s.message.content.length];
45
+
46
+ for (const part of s.message.content) {
47
+ if (part.type === "text" || part.type === "reasoning") {
48
+ values.push(part.type, part.text);
49
+ } else if (part.type === "tool-call") {
50
+ values.push(part.type, part.argsText, part.result !== undefined);
51
+ } else {
52
+ values.push(part.type);
53
+ }
47
54
  }
48
- }
49
- return `${s.message.content.length}:${size}`;
50
- });
55
+ return values;
56
+ }),
57
+ );
51
58
 
52
- const running = fingerprint !== undefined;
59
+ const running = activity[0] === true;
53
60
  const lastActivityRef = useRef(Date.now());
54
61
  const [stalled, setStalled] = useState(false);
55
62
  const [, setTick] = useState(0);
@@ -58,7 +65,7 @@ export function unstable_useMessageStallDetection(
58
65
  if (!running) return undefined;
59
66
  lastActivityRef.current = Date.now();
60
67
  return undefined;
61
- }, [running, fingerprint]);
68
+ }, [running, activity]);
62
69
 
63
70
  useEffect(() => {
64
71
  if (!running) {
@@ -75,7 +82,7 @@ export function unstable_useMessageStallDetection(
75
82
  setStalled(false);
76
83
  const id = setTimeout(() => setStalled(true), thresholdMs - sinceActivity);
77
84
  return () => clearTimeout(id);
78
- }, [running, fingerprint, thresholdMs]);
85
+ }, [running, activity, thresholdMs]);
79
86
 
80
87
  useEffect(() => {
81
88
  if (!stalled) return undefined;