@assistant-ui/react 0.15.22 → 0.15.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +44 -23
- package/dist/assistant-transport.js.map +1 -1
- package/dist/cloud-renderer/CloudRendererHost.js.map +1 -1
- package/dist/context/providers/MessageProvider.js.map +1 -1
- package/dist/context/providers/ThreadViewportProvider.js.map +1 -1
- package/dist/hooks/useToolCallElapsed.js.map +1 -1
- package/dist/legacy-runtime/AssistantRuntimeProvider.js.map +1 -1
- package/dist/mcp-apps/app-frame.js.map +1 -1
- package/dist/model-context/frame/useAssistantFrameHost.js.map +1 -1
- package/dist/model-context/makeAssistantVisible.js.map +1 -1
- package/dist/primitives/actionBar/ActionBarCopy.js.map +1 -1
- package/dist/primitives/actionBar/ActionBarEdit.js.map +1 -1
- package/dist/primitives/actionBar/ActionBarExportMarkdown.js.map +1 -1
- package/dist/primitives/actionBar/ActionBarFeedbackNegative.js.map +1 -1
- package/dist/primitives/actionBar/ActionBarFeedbackPositive.js.map +1 -1
- package/dist/primitives/actionBar/ActionBarReload.js.map +1 -1
- package/dist/primitives/actionBar/ActionBarRoot.js.map +1 -1
- package/dist/primitives/actionBar/ActionBarSpeak.js.map +1 -1
- package/dist/primitives/actionBar/ActionBarStopSpeaking.js.map +1 -1
- package/dist/primitives/actionBar/useActionBarFloatStatus.js.map +1 -1
- package/dist/primitives/actionBarMore/ActionBarMoreContent.js.map +1 -1
- package/dist/primitives/actionBarMore/ActionBarMoreItem.js.map +1 -1
- package/dist/primitives/actionBarMore/ActionBarMoreRoot.js.map +1 -1
- package/dist/primitives/actionBarMore/ActionBarMoreSeparator.js.map +1 -1
- package/dist/primitives/actionBarMore/ActionBarMoreTrigger.js.map +1 -1
- package/dist/primitives/actionBarMore/scope.js.map +1 -1
- package/dist/primitives/assistantModal/AssistantModalAnchor.js.map +1 -1
- package/dist/primitives/assistantModal/AssistantModalContent.js.map +1 -1
- package/dist/primitives/assistantModal/AssistantModalRoot.js.map +1 -1
- package/dist/primitives/assistantModal/AssistantModalTrigger.js.map +1 -1
- package/dist/primitives/assistantModal/popoverRenderPrimitives.js.map +1 -1
- package/dist/primitives/assistantModal/scope.js.map +1 -1
- package/dist/primitives/attachment/AttachmentName.js.map +1 -1
- package/dist/primitives/attachment/AttachmentRemove.js.map +1 -1
- package/dist/primitives/attachment/AttachmentRoot.js.map +1 -1
- package/dist/primitives/attachment/AttachmentThumb.js.map +1 -1
- package/dist/primitives/branchPicker/BranchPickerCount.js.map +1 -1
- package/dist/primitives/branchPicker/BranchPickerNext.js.map +1 -1
- package/dist/primitives/branchPicker/BranchPickerNumber.js.map +1 -1
- package/dist/primitives/branchPicker/BranchPickerPrevious.js.map +1 -1
- package/dist/primitives/branchPicker/BranchPickerRoot.js.map +1 -1
- package/dist/primitives/chainOfThought/ChainOfThoughtAccordionTrigger.js.map +1 -1
- package/dist/primitives/chainOfThought/ChainOfThoughtRoot.js.map +1 -1
- package/dist/primitives/composer/ComposerAddAttachment.js.map +1 -1
- package/dist/primitives/composer/ComposerCancel.js.map +1 -1
- package/dist/primitives/composer/ComposerDictate.js.map +1 -1
- package/dist/primitives/composer/ComposerDictationTranscript.js.map +1 -1
- package/dist/primitives/composer/ComposerInput.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerInput.js +3 -2
- package/dist/primitives/composer/ComposerInput.js.map +1 -1
- package/dist/primitives/composer/ComposerInputPluginContext.js.map +1 -1
- package/dist/primitives/composer/ComposerQuote.js.map +1 -1
- package/dist/primitives/composer/ComposerRoot.js.map +1 -1
- package/dist/primitives/composer/ComposerSend.js.map +1 -1
- package/dist/primitives/composer/ComposerStopDictation.js.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopover.js.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverAction.js.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverBack.js.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverCategories.js.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverDirective.js.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverItems.js.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverResource.js.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverRootContext.js.map +1 -1
- package/dist/primitives/composer/trigger/index.js.map +1 -1
- package/dist/primitives/composer/trigger/triggerDetectionResource.js.map +1 -1
- package/dist/primitives/composer/trigger/triggerKeyboardResource.js.map +1 -1
- package/dist/primitives/composer/trigger/triggerNavigationResource.js.map +1 -1
- package/dist/primitives/composer/trigger/triggerSelectionResource.js.map +1 -1
- package/dist/primitives/composer/useComposerInputState.js.map +1 -1
- package/dist/primitives/dropdownMenuRenderPrimitives.js.map +1 -1
- package/dist/primitives/error/ErrorMessage.js.map +1 -1
- package/dist/primitives/error/ErrorRoot.js.map +1 -1
- package/dist/primitives/message/MessageIf.d.ts.map +1 -1
- package/dist/primitives/message/MessageIf.js +6 -3
- package/dist/primitives/message/MessageIf.js.map +1 -1
- package/dist/primitives/message/MessageParts.js.map +1 -1
- package/dist/primitives/message/MessagePartsGrouped.d.ts.map +1 -1
- package/dist/primitives/message/MessagePartsGrouped.js +42 -17
- package/dist/primitives/message/MessagePartsGrouped.js.map +1 -1
- package/dist/primitives/message/MessageRoot.js.map +1 -1
- package/dist/primitives/messagePart/MessagePartImage.js.map +1 -1
- package/dist/primitives/queueItem/QueueItemRemove.js.map +1 -1
- package/dist/primitives/queueItem/QueueItemSteer.js.map +1 -1
- package/dist/primitives/queueItem/QueueItemText.js.map +1 -1
- package/dist/primitives/reasoning/useScrollLock.js.map +1 -1
- package/dist/primitives/selectionToolbar/SelectionToolbarQuote.js.map +1 -1
- package/dist/primitives/selectionToolbar/SelectionToolbarRoot.js.map +1 -1
- package/dist/primitives/suggestion/SuggestionDescription.js.map +1 -1
- package/dist/primitives/suggestion/SuggestionTitle.js.map +1 -1
- package/dist/primitives/suggestion/SuggestionTrigger.js.map +1 -1
- package/dist/primitives/thread/ThreadIf.js.map +1 -1
- package/dist/primitives/thread/ThreadRoot.d.ts +3 -2
- package/dist/primitives/thread/ThreadRoot.d.ts.map +1 -1
- package/dist/primitives/thread/ThreadRoot.js +49 -27
- package/dist/primitives/thread/ThreadRoot.js.map +1 -1
- package/dist/primitives/thread/ThreadScrollToBottom.js.map +1 -1
- package/dist/primitives/thread/ThreadSuggestion.js.map +1 -1
- package/dist/primitives/thread/ThreadViewport.js.map +1 -1
- package/dist/primitives/thread/ThreadViewportFooter.js.map +1 -1
- package/dist/primitives/thread/topAnchor/useTopAnchorReserve.js.map +1 -1
- package/dist/primitives/threadList/ThreadListLoadMore.js.map +1 -1
- package/dist/primitives/threadList/ThreadListNew.js.map +1 -1
- package/dist/primitives/threadList/ThreadListRoot.js.map +1 -1
- package/dist/primitives/threadListItem/ThreadListItemArchive.js.map +1 -1
- package/dist/primitives/threadListItem/ThreadListItemDelete.js.map +1 -1
- package/dist/primitives/threadListItem/ThreadListItemRoot.d.ts.map +1 -1
- package/dist/primitives/threadListItem/ThreadListItemRoot.js +1 -0
- package/dist/primitives/threadListItem/ThreadListItemRoot.js.map +1 -1
- package/dist/primitives/threadListItem/ThreadListItemTrigger.js.map +1 -1
- package/dist/primitives/threadListItem/ThreadListItemUnarchive.js.map +1 -1
- package/dist/primitives/threadListItemMore/ThreadListItemMoreContent.d.ts.map +1 -1
- package/dist/primitives/threadListItemMore/ThreadListItemMoreContent.js +1 -0
- package/dist/primitives/threadListItemMore/ThreadListItemMoreContent.js.map +1 -1
- package/dist/primitives/threadListItemMore/ThreadListItemMoreItem.js.map +1 -1
- package/dist/primitives/threadListItemMore/ThreadListItemMoreRoot.js.map +1 -1
- package/dist/primitives/threadListItemMore/ThreadListItemMoreSeparator.js.map +1 -1
- package/dist/primitives/threadListItemMore/ThreadListItemMoreTrigger.d.ts.map +1 -1
- package/dist/primitives/threadListItemMore/ThreadListItemMoreTrigger.js +1 -0
- package/dist/primitives/threadListItemMore/ThreadListItemMoreTrigger.js.map +1 -1
- package/dist/primitives/threadListItemMore/scope.js.map +1 -1
- package/dist/sandbox-host/SandboxHost.js.map +1 -1
- package/dist/unstable/useModelContextSnapshot.js.map +1 -1
- package/dist/unstable/webmcp/WebMcpRegistrationResource.d.ts +1 -0
- package/dist/unstable/webmcp/WebMcpRegistrationResource.d.ts.map +1 -1
- package/dist/unstable/webmcp/WebMcpRegistrationResource.js +27 -39
- package/dist/unstable/webmcp/WebMcpRegistrationResource.js.map +1 -1
- package/dist/unstable/webmcp/useWebMcpProvider.d.ts.map +1 -1
- package/dist/unstable/webmcp/useWebMcpProvider.js +22 -13
- package/dist/unstable/webmcp/useWebMcpProvider.js.map +1 -1
- package/dist/utils/Primitive.js.map +1 -1
- package/dist/utils/createActionButton.js.map +1 -1
- package/dist/utils/hooks/useManagedRef.js.map +1 -1
- package/dist/utils/hooks/useMediaQuery.js.map +1 -1
- package/dist/utils/hooks/useOnResizeContent.js.map +1 -1
- package/dist/utils/hooks/useOnScrollToBottom.js.map +1 -1
- package/dist/utils/hooks/useSizeHandle.js.map +1 -1
- package/dist/utils/isCompositionKey.d.ts +2 -0
- package/dist/utils/isCompositionKey.d.ts.map +1 -0
- package/dist/utils/isCompositionKey.js +6 -0
- package/dist/utils/isCompositionKey.js.map +1 -0
- package/dist/utils/smooth/SmoothContext.js.map +1 -1
- package/package.json +15 -12
- package/src/primitives/composer/ComposerInput.test.tsx +33 -1
- package/src/primitives/composer/ComposerInput.tsx +3 -2
- package/src/primitives/message/MessageIf.test.tsx +72 -0
- package/src/primitives/message/MessageIf.ts +8 -3
- package/src/primitives/message/MessagePartsGrouped.test.tsx +271 -1
- package/src/primitives/message/MessagePartsGrouped.tsx +14 -6
- package/src/primitives/thread/ThreadRoot.test.tsx +163 -0
- package/src/primitives/thread/ThreadRoot.tsx +33 -3
- package/src/primitives/threadListItem/ThreadListItemRoot.tsx +2 -0
- package/src/primitives/threadListItem/ThreadListKeyboardNav.test.tsx +65 -0
- package/src/primitives/threadListItemMore/ThreadListItemMoreContent.tsx +7 -0
- package/src/primitives/threadListItemMore/ThreadListItemMoreTrigger.tsx +2 -0
- package/src/tests/RemoteThreadListRuntime.reloadMainThread.test.tsx +8 -13
- package/src/tests/remote-thread-list-test-helpers.ts +15 -0
- package/src/tests/threadListItemIsRunning.test.tsx +10 -7
- package/src/unstable/webmcp/WebMcpRegistrationResource.ts +9 -8
- package/src/unstable/webmcp/useWebMcpProvider.test.tsx +36 -2
- package/src/unstable/webmcp/useWebMcpProvider.ts +40 -13
- package/src/utils/isCompositionKey.ts +5 -0
|
@@ -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 };
|
|
@@ -227,6 +357,39 @@ describe("ThreadPrimitiveRoot", () => {
|
|
|
227
357
|
expect(event.defaultPrevented).toBe(true);
|
|
228
358
|
});
|
|
229
359
|
|
|
360
|
+
it.each([
|
|
361
|
+
["during the composition", { isComposing: true }],
|
|
362
|
+
["after compositionend (Safari)", { keyCode: 229 }],
|
|
363
|
+
])(
|
|
364
|
+
"keeps speech playing when Escape ends an IME composition %s",
|
|
365
|
+
async (_, init) => {
|
|
366
|
+
const speech = createSpeechAdapter();
|
|
367
|
+
const runtimeRef: RuntimeRef = { current: null };
|
|
368
|
+
render(
|
|
369
|
+
<RuntimeProvider runtimeRef={runtimeRef} speech={speech.adapter}>
|
|
370
|
+
<ThreadPrimitiveRoot>
|
|
371
|
+
<ComposerPrimitiveInput data-testid="composer" />
|
|
372
|
+
</ThreadPrimitiveRoot>
|
|
373
|
+
</RuntimeProvider>,
|
|
374
|
+
);
|
|
375
|
+
startSpeaking(runtimeRef);
|
|
376
|
+
await waitFor(() => {
|
|
377
|
+
expect(runtimeRef.current!.thread.getState().speech).toBeDefined();
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
const event = new KeyboardEvent("keydown", {
|
|
381
|
+
key: "Escape",
|
|
382
|
+
bubbles: true,
|
|
383
|
+
cancelable: true,
|
|
384
|
+
...init,
|
|
385
|
+
});
|
|
386
|
+
act(() => screen.getByTestId("composer").dispatchEvent(event));
|
|
387
|
+
|
|
388
|
+
expect(speech.cancel).not.toHaveBeenCalled();
|
|
389
|
+
expect(event.defaultPrevented).toBe(false);
|
|
390
|
+
},
|
|
391
|
+
);
|
|
392
|
+
|
|
230
393
|
it("lets an idle composer fall through so Escape still stops speech", async () => {
|
|
231
394
|
const speech = createSpeechAdapter();
|
|
232
395
|
const runtimeRef: RuntimeRef = { current: null };
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { Primitive } from "../../utils/Primitive";
|
|
4
|
+
import { isCompositionKey } from "../../utils/isCompositionKey";
|
|
4
5
|
import {
|
|
5
6
|
type ComponentRef,
|
|
6
7
|
forwardRef,
|
|
7
8
|
type ComponentPropsWithoutRef,
|
|
9
|
+
type KeyboardEventHandler,
|
|
8
10
|
useEffect,
|
|
9
11
|
useRef,
|
|
10
12
|
} from "react";
|
|
@@ -12,6 +14,8 @@ import { useAui } from "@assistant-ui/store";
|
|
|
12
14
|
import { useComposedRefs } from "radix-ui/internal";
|
|
13
15
|
import { ThreadRootElementContext } from "./ThreadRootElementContext";
|
|
14
16
|
|
|
17
|
+
const escapeEventThreadRoots = new WeakMap<KeyboardEvent, Element>();
|
|
18
|
+
|
|
15
19
|
export namespace ThreadPrimitiveRoot {
|
|
16
20
|
export type Element = ComponentRef<typeof Primitive.div>;
|
|
17
21
|
/**
|
|
@@ -27,8 +31,9 @@ export namespace ThreadPrimitiveRoot {
|
|
|
27
31
|
* This component serves as the foundational wrapper for all thread-related components.
|
|
28
32
|
* It provides the basic structure and context needed for thread functionality.
|
|
29
33
|
*
|
|
30
|
-
* While this component is mounted, an unhandled Escape keydown
|
|
31
|
-
* the action bar that started it is no longer mounted.
|
|
34
|
+
* While this component is mounted, an unhandled Escape keydown inside this thread stops its
|
|
35
|
+
* active speech, even if the action bar that started it is no longer mounted. Escape outside
|
|
36
|
+
* every thread retains the document-level fallback.
|
|
32
37
|
*
|
|
33
38
|
* @example
|
|
34
39
|
* ```tsx
|
|
@@ -48,11 +53,31 @@ export const ThreadPrimitiveRoot = forwardRef<
|
|
|
48
53
|
const aui = useAui();
|
|
49
54
|
const rootRef = useRef<ThreadPrimitiveRoot.Element>(null);
|
|
50
55
|
const composedRef = useComposedRefs(ref, rootRef);
|
|
56
|
+
const handleRootKeyDown: KeyboardEventHandler<ThreadPrimitiveRoot.Element> = (
|
|
57
|
+
event,
|
|
58
|
+
) => {
|
|
59
|
+
if (event.key === "Escape" && rootRef.current) {
|
|
60
|
+
escapeEventThreadRoots.set(event.nativeEvent, rootRef.current);
|
|
61
|
+
}
|
|
62
|
+
props.onKeyDown?.(event);
|
|
63
|
+
};
|
|
51
64
|
|
|
52
65
|
useEffect(() => {
|
|
53
66
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
54
67
|
if (event.key !== "Escape") return;
|
|
68
|
+
// An Escape that ends an IME composition belongs to the input method.
|
|
69
|
+
if (isCompositionKey(event)) return;
|
|
55
70
|
if (event.defaultPrevented || aui.thread.source === null) return;
|
|
71
|
+
const eventThreadRoot =
|
|
72
|
+
escapeEventThreadRoots.get(event) ??
|
|
73
|
+
event
|
|
74
|
+
.composedPath()
|
|
75
|
+
.find(
|
|
76
|
+
(target) =>
|
|
77
|
+
target instanceof Element &&
|
|
78
|
+
target.hasAttribute("data-aui-thread-root"),
|
|
79
|
+
);
|
|
80
|
+
if (eventThreadRoot && eventThreadRoot !== rootRef.current) return;
|
|
56
81
|
if (aui.thread.getState().speech == null) return;
|
|
57
82
|
event.preventDefault();
|
|
58
83
|
try {
|
|
@@ -77,7 +102,12 @@ export const ThreadPrimitiveRoot = forwardRef<
|
|
|
77
102
|
|
|
78
103
|
return (
|
|
79
104
|
<ThreadRootElementContext.Provider value={rootRef}>
|
|
80
|
-
<Primitive.div
|
|
105
|
+
<Primitive.div
|
|
106
|
+
{...props}
|
|
107
|
+
data-aui-thread-root=""
|
|
108
|
+
ref={composedRef}
|
|
109
|
+
onKeyDown={handleRootKeyDown}
|
|
110
|
+
/>
|
|
81
111
|
</ThreadRootElementContext.Provider>
|
|
82
112
|
);
|
|
83
113
|
});
|
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
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
|
|
176
|
-
|
|
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
|
|
186
|
-
|
|
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");
|
|
@@ -10,6 +10,7 @@ export type WebMcpRegistrationProps = {
|
|
|
10
10
|
/** Re-registers when it changes; an execute-only edit reads through instead. */
|
|
11
11
|
signature: string;
|
|
12
12
|
tool: Tool<any, any>;
|
|
13
|
+
getCurrentTool: (name: string) => Tool<any, any> | undefined;
|
|
13
14
|
};
|
|
14
15
|
|
|
15
16
|
// The explainer specifies NotAllowedError for a page whose tools permission is
|
|
@@ -26,16 +27,12 @@ const useWebMcpRegistration = ({
|
|
|
26
27
|
name,
|
|
27
28
|
signature,
|
|
28
29
|
tool,
|
|
30
|
+
getCurrentTool,
|
|
29
31
|
}: WebMcpRegistrationProps): string | null => {
|
|
30
32
|
// A refused name is remembered for as long as the tool stays in the model
|
|
31
33
|
// context, so a permanent collision warns once rather than on every sync.
|
|
32
34
|
const [refused, setRefused] = useState(false);
|
|
33
|
-
const
|
|
34
|
-
// Commit phase, so an abandoned render cannot hand the host a tool the tree
|
|
35
|
-
// never committed.
|
|
36
|
-
useEffect(() => {
|
|
37
|
-
toolRef.current = tool;
|
|
38
|
-
}, [tool]);
|
|
35
|
+
const fallbackToolRef = useRef(tool);
|
|
39
36
|
|
|
40
37
|
useEffect(() => {
|
|
41
38
|
if (refused) return undefined;
|
|
@@ -53,7 +50,11 @@ const useWebMcpRegistration = ({
|
|
|
53
50
|
|
|
54
51
|
try {
|
|
55
52
|
dispose = host.registerTool(
|
|
56
|
-
toWebMcpTool(
|
|
53
|
+
toWebMcpTool(
|
|
54
|
+
name,
|
|
55
|
+
() => getCurrentTool(name) ?? fallbackToolRef.current,
|
|
56
|
+
lifecycle.signal,
|
|
57
|
+
),
|
|
57
58
|
(error) =>
|
|
58
59
|
refuse(
|
|
59
60
|
notPermitted(error)
|
|
@@ -84,7 +85,7 @@ const useWebMcpRegistration = ({
|
|
|
84
85
|
);
|
|
85
86
|
}
|
|
86
87
|
};
|
|
87
|
-
}, [host, name, signature, refused]);
|
|
88
|
+
}, [getCurrentTool, host, name, signature, refused]);
|
|
88
89
|
|
|
89
90
|
return refused ? null : name;
|
|
90
91
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
|
|
3
|
-
import { StrictMode, type ReactNode } from "react";
|
|
4
|
-
import { cleanup } from "@testing-library/react";
|
|
3
|
+
import { StrictMode, type ReactNode, useLayoutEffect } from "react";
|
|
4
|
+
import { act, cleanup } from "@testing-library/react";
|
|
5
5
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
6
|
import type { Tool } from "assistant-stream";
|
|
7
7
|
import type {
|
|
@@ -195,6 +195,40 @@ describe("unstable_useWebMcpProvider", () => {
|
|
|
195
195
|
expect(host.unregisterCalls).toEqual([]);
|
|
196
196
|
});
|
|
197
197
|
|
|
198
|
+
it("calls through to the committed implementation from a descendant layout effect", async () => {
|
|
199
|
+
const host = useHost(createFakeWebMcpHost());
|
|
200
|
+
const provider = createProvider({
|
|
201
|
+
search: frontendTool({ execute: async () => "first" }),
|
|
202
|
+
});
|
|
203
|
+
let invoke = false;
|
|
204
|
+
let execution: ReturnType<WebMcpToolDescriptor["execute"]> | undefined;
|
|
205
|
+
|
|
206
|
+
const InvokeOnCommit = ({ children }: { children: ReactNode }) => {
|
|
207
|
+
useLayoutEffect(() => {
|
|
208
|
+
if (invoke) execution = host.registry.get("search")?.execute({});
|
|
209
|
+
});
|
|
210
|
+
return children;
|
|
211
|
+
};
|
|
212
|
+
const { rerender } = mountProvider(provider, {}, (children) => (
|
|
213
|
+
<InvokeOnCommit>{children}</InvokeOnCommit>
|
|
214
|
+
));
|
|
215
|
+
await waitForNames(["search"]);
|
|
216
|
+
|
|
217
|
+
act(() => {
|
|
218
|
+
invoke = true;
|
|
219
|
+
provider.setTools({
|
|
220
|
+
search: frontendTool({ execute: async () => "second" }),
|
|
221
|
+
});
|
|
222
|
+
rerender({});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
await expect(execution).resolves.toEqual({
|
|
226
|
+
content: [{ type: "text", text: "second" }],
|
|
227
|
+
});
|
|
228
|
+
expect(host.registerCalls).toEqual(["search"]);
|
|
229
|
+
expect(host.unregisterCalls).toEqual([]);
|
|
230
|
+
});
|
|
231
|
+
|
|
198
232
|
it("re-registers when the description or the schema changes", async () => {
|
|
199
233
|
const host = useHost(createFakeWebMcpHost());
|
|
200
234
|
const provider = createProvider({ search: frontendTool() });
|
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
useCallback,
|
|
5
|
+
useEffect,
|
|
6
|
+
useInsertionEffect,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
useSyncExternalStore,
|
|
10
|
+
} from "react";
|
|
4
11
|
import { useResources, useTapRoot, withKey } from "@assistant-ui/tap";
|
|
5
|
-
import { useAui
|
|
12
|
+
import { useAui } from "@assistant-ui/store";
|
|
6
13
|
import type { Tool } from "assistant-stream";
|
|
7
14
|
import { getDefaultWebMcpHost, type WebMcpHost } from "./webmcp-host";
|
|
8
15
|
import { defaultWebMcpFilter, toWebMcpInputSchema } from "./convertTools";
|
|
@@ -73,20 +80,16 @@ const useStableNames = (names: readonly (string | null)[]) => {
|
|
|
73
80
|
};
|
|
74
81
|
|
|
75
82
|
const useWebMcpRegistry = ({
|
|
76
|
-
aui,
|
|
77
83
|
host,
|
|
78
84
|
filter,
|
|
85
|
+
tools,
|
|
86
|
+
getCurrentTool,
|
|
79
87
|
}: {
|
|
80
|
-
aui: AssistantClient;
|
|
81
88
|
host: WebMcpHost;
|
|
82
89
|
filter: (name: string, tool: Tool<any, any>) => boolean;
|
|
90
|
+
tools: Record<string, Tool<any, any>>;
|
|
91
|
+
getCurrentTool: (name: string) => Tool<any, any> | undefined;
|
|
83
92
|
}) => {
|
|
84
|
-
const tools = useModelContextSnapshot(
|
|
85
|
-
aui,
|
|
86
|
-
host.available,
|
|
87
|
-
modelContextToolSource,
|
|
88
|
-
);
|
|
89
|
-
|
|
90
93
|
const elements = [];
|
|
91
94
|
for (const [name, tool] of Object.entries(tools)) {
|
|
92
95
|
try {
|
|
@@ -95,8 +98,14 @@ const useWebMcpRegistry = ({
|
|
|
95
98
|
elements.push(
|
|
96
99
|
withKey(
|
|
97
100
|
name,
|
|
98
|
-
WebMcpRegistrationResource({
|
|
99
|
-
|
|
101
|
+
WebMcpRegistrationResource({
|
|
102
|
+
host,
|
|
103
|
+
name,
|
|
104
|
+
signature,
|
|
105
|
+
tool,
|
|
106
|
+
getCurrentTool,
|
|
107
|
+
}),
|
|
108
|
+
[host, name, signature, tool, getCurrentTool],
|
|
100
109
|
),
|
|
101
110
|
);
|
|
102
111
|
} catch (error) {
|
|
@@ -126,9 +135,27 @@ export const unstable_useWebMcpProvider = (
|
|
|
126
135
|
const aui = useAui();
|
|
127
136
|
const [host] = useState(getDefaultWebMcpHost);
|
|
128
137
|
const filter = options.filter ?? defaultWebMcpFilter;
|
|
138
|
+
const tools = useModelContextSnapshot(
|
|
139
|
+
aui,
|
|
140
|
+
host.available,
|
|
141
|
+
modelContextToolSource,
|
|
142
|
+
);
|
|
143
|
+
const currentToolsRef = useRef(tools);
|
|
144
|
+
useInsertionEffect(() => {
|
|
145
|
+
currentToolsRef.current = tools;
|
|
146
|
+
}, [tools]);
|
|
147
|
+
const getCurrentTool = useCallback(
|
|
148
|
+
(name: string) => currentToolsRef.current[name],
|
|
149
|
+
[],
|
|
150
|
+
);
|
|
129
151
|
|
|
130
152
|
const root = useTapRoot(function WebMcpProviderRoot() {
|
|
131
|
-
return useWebMcpRegistry({
|
|
153
|
+
return useWebMcpRegistry({
|
|
154
|
+
host,
|
|
155
|
+
filter,
|
|
156
|
+
tools,
|
|
157
|
+
getCurrentTool,
|
|
158
|
+
});
|
|
132
159
|
});
|
|
133
160
|
const registeredToolNames = useSyncExternalStore(
|
|
134
161
|
root.subscribe,
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// A keydown that ends an IME composition can arrive after compositionend with
|
|
2
|
+
// isComposing false and keyCode 229, as the Enter that commits one does in
|
|
3
|
+
// Safari.
|
|
4
|
+
export const isCompositionKey = (event: KeyboardEvent) =>
|
|
5
|
+
event.isComposing || event.keyCode === 229;
|