@assistant-ui/react 0.15.13 → 0.15.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assistant-transport.d.ts +32 -0
- package/dist/assistant-transport.d.ts.map +1 -0
- package/dist/assistant-transport.js +27 -0
- package/dist/assistant-transport.js.map +1 -0
- package/dist/context/providers/MessageProvider.js +1 -1
- package/dist/hooks/useMessageQuote.js +1 -1
- package/dist/hooks/useToolCallElapsed.js +1 -1
- package/dist/index.d.ts +5 -8
- package/dist/index.js +7 -9
- package/dist/legacy-runtime/AssistantRuntimeProvider.js +1 -1
- package/dist/mcp-apps/McpAppsRemoteHost.d.ts.map +1 -1
- package/dist/mcp-apps/McpAppsRemoteHost.js +18 -2
- package/dist/mcp-apps/McpAppsRemoteHost.js.map +1 -1
- package/dist/mcp-apps/bridge.js +39 -24
- package/dist/mcp-apps/bridge.js.map +1 -1
- package/dist/model-context/makeAssistantVisible.js +1 -1
- package/dist/primitives/actionBar/ActionBarCopy.js +1 -1
- package/dist/primitives/actionBar/ActionBarExportMarkdown.js +1 -1
- package/dist/primitives/actionBar/ActionBarFeedbackNegative.js +1 -1
- package/dist/primitives/actionBar/ActionBarFeedbackPositive.js +1 -1
- package/dist/primitives/actionBar/ActionBarStopSpeaking.d.ts.map +1 -1
- package/dist/primitives/actionBar/ActionBarStopSpeaking.js +24 -37
- package/dist/primitives/actionBar/ActionBarStopSpeaking.js.map +1 -1
- package/dist/primitives/actionBar/useActionBarFloatStatus.js +1 -1
- package/dist/primitives/assistantModal/AssistantModalRoot.js +1 -1
- package/dist/primitives/attachment/AttachmentName.js +1 -1
- package/dist/primitives/attachment/AttachmentRemove.js +1 -1
- package/dist/primitives/attachment/AttachmentThumb.js +1 -1
- package/dist/primitives/branchPicker/BranchPickerCount.js +1 -1
- package/dist/primitives/branchPicker/BranchPickerNumber.js +1 -1
- package/dist/primitives/chainOfThought/ChainOfThoughtAccordionTrigger.js +1 -1
- package/dist/primitives/composer/ComposerAddAttachment.js +1 -1
- package/dist/primitives/composer/ComposerAttachmentDropzone.js +1 -1
- package/dist/primitives/composer/ComposerDictationTranscript.js +1 -1
- package/dist/primitives/composer/ComposerInput.js +2 -2
- package/dist/primitives/composer/ComposerInput.js.map +1 -1
- package/dist/primitives/composer/ComposerQuote.js +1 -1
- package/dist/primitives/composer/ComposerRoot.js +1 -1
- package/dist/primitives/composer/ComposerStopDictation.js +1 -1
- package/dist/primitives/composer/trigger/TriggerPopover.js +1 -1
- package/dist/primitives/composer/useComposerInputState.js +1 -1
- package/dist/primitives/message/MessageIf.js +1 -1
- package/dist/primitives/message/MessagePartsGrouped.js +1 -1
- package/dist/primitives/message/MessageRoot.js +1 -1
- package/dist/primitives/queueItem/QueueItemRemove.js +1 -1
- package/dist/primitives/queueItem/QueueItemSteer.js +1 -1
- package/dist/primitives/queueItem/QueueItemText.js +1 -1
- package/dist/primitives/selectionToolbar/SelectionToolbarQuote.js +1 -1
- package/dist/primitives/suggestion/SuggestionDescription.js +1 -1
- package/dist/primitives/suggestion/SuggestionTitle.js +1 -1
- package/dist/primitives/suggestion/SuggestionTrigger.js +1 -1
- package/dist/primitives/thread/ThreadIf.js +1 -1
- package/dist/primitives/thread/ThreadRoot.d.ts.map +1 -1
- package/dist/primitives/thread/ThreadRoot.js +41 -9
- package/dist/primitives/thread/ThreadRoot.js.map +1 -1
- package/dist/primitives/thread/ThreadViewport.d.ts.map +1 -1
- package/dist/primitives/thread/ThreadViewport.js +2 -13
- package/dist/primitives/thread/ThreadViewport.js.map +1 -1
- package/dist/primitives/thread/useThreadViewportAutoScroll.d.ts.map +1 -1
- package/dist/primitives/thread/useThreadViewportAutoScroll.js +23 -8
- package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
- package/dist/primitives/threadList/ThreadListNew.js +1 -1
- package/dist/sandbox-host/SandboxHost.js +2 -0
- package/dist/sandbox-host/SandboxHost.js.map +1 -1
- package/dist/unstable/useComposerInput.d.ts +2 -2
- package/dist/unstable/useComposerInput.js +1 -1
- package/dist/unstable/useComposerInput.js.map +1 -1
- package/dist/unstable/useComposerInputHistory.js +1 -1
- package/dist/unstable/useComposerInputHistory.js.map +1 -1
- package/dist/unstable/useLiveCompletionAdapter.d.ts +5 -0
- package/dist/unstable/useLiveCompletionAdapter.d.ts.map +1 -1
- package/dist/unstable/useLiveCompletionAdapter.js +16 -3
- package/dist/unstable/useLiveCompletionAdapter.js.map +1 -1
- package/dist/unstable/useMentionAdapter.d.ts +1 -1
- package/dist/utils/smooth/SmoothContext.js +1 -1
- package/dist/utils/useToolArgsFieldStatus.js +1 -1
- package/package.json +9 -9
- package/src/assistant-transport.ts +94 -0
- package/src/index.ts +9 -4
- package/src/mcp-apps/McpAppsRemoteHost.test.ts +29 -3
- package/src/mcp-apps/McpAppsRemoteHost.ts +42 -7
- package/src/mcp-apps/bridge.test.ts +132 -0
- package/src/mcp-apps/bridge.ts +54 -30
- package/src/primitives/actionBar/ActionBarStopSpeaking.tsx +0 -9
- package/src/primitives/composer/ComposerInput.tsx +1 -1
- package/src/primitives/thread/ThreadRoot.test.tsx +251 -0
- package/src/primitives/thread/ThreadRoot.tsx +30 -0
- package/src/primitives/thread/ThreadViewport.tsx +1 -8
- package/src/primitives/thread/useThreadViewportAutoScroll.test.tsx +152 -0
- package/src/primitives/thread/useThreadViewportAutoScroll.ts +26 -19
- package/src/sandbox-host/SandboxHost.test.tsx +29 -0
- package/src/sandbox-host/SandboxHost.tsx +2 -0
- package/src/tests/RemoteThreadListRuntime.adapterProvider.test.tsx +24 -3
- package/src/tests/RemoteThreadListRuntime.reloadMainThread.test.tsx +1 -2
- package/src/tests/augmentations.test.ts +49 -0
- package/src/tests/in-memory-thread-list.test.tsx +87 -3
- package/src/tests/threadMessageById.test.tsx +61 -1
- package/src/unstable/useComposerInput.ts +2 -2
- package/src/unstable/useComposerInputHistory.test.tsx +4 -4
- package/src/unstable/useComposerInputHistory.ts +1 -1
- package/src/unstable/useLiveCompletionAdapter.test.tsx +100 -0
- package/src/unstable/useLiveCompletionAdapter.ts +41 -17
- package/dist/client/InMemoryThreadList.d.ts +0 -12
- package/dist/client/InMemoryThreadList.d.ts.map +0 -1
- package/dist/client/InMemoryThreadList.js +0 -365
- package/dist/client/InMemoryThreadList.js.map +0 -1
- package/dist/legacy-runtime/cloud/auiV0.d.ts +0 -119
- package/dist/legacy-runtime/cloud/auiV0.d.ts.map +0 -1
- package/dist/legacy-runtime/cloud/auiV0.js +0 -137
- package/dist/legacy-runtime/cloud/auiV0.js.map +0 -1
- package/dist/legacy-runtime/cloud/useCloudThreadListRuntime.d.ts +0 -16
- package/dist/legacy-runtime/cloud/useCloudThreadListRuntime.d.ts.map +0 -1
- package/dist/legacy-runtime/cloud/useCloudThreadListRuntime.js +0 -36
- package/dist/legacy-runtime/cloud/useCloudThreadListRuntime.js.map +0 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/commandQueue.d.ts +0 -17
- package/dist/legacy-runtime/runtime-cores/assistant-transport/commandQueue.d.ts.map +0 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/commandQueue.js +0 -55
- package/dist/legacy-runtime/runtime-cores/assistant-transport/commandQueue.js.map +0 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/replayBoundaryStream.d.ts +0 -11
- package/dist/legacy-runtime/runtime-cores/assistant-transport/replayBoundaryStream.d.ts.map +0 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/replayBoundaryStream.js +0 -141
- package/dist/legacy-runtime/runtime-cores/assistant-transport/replayBoundaryStream.js.map +0 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/runManager.d.ts +0 -15
- package/dist/legacy-runtime/runtime-cores/assistant-transport/runManager.d.ts.map +0 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/runManager.js +0 -81
- package/dist/legacy-runtime/runtime-cores/assistant-transport/runManager.js.map +0 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/types.d.ts +0 -126
- package/dist/legacy-runtime/runtime-cores/assistant-transport/types.d.ts.map +0 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/types.js +0 -0
- package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.d.ts +0 -14
- package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.d.ts.map +0 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.js +0 -290
- package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.js.map +0 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/useConvertedState.d.ts +0 -7
- package/dist/legacy-runtime/runtime-cores/assistant-transport/useConvertedState.d.ts.map +0 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/useConvertedState.js +0 -25
- package/dist/legacy-runtime/runtime-cores/assistant-transport/useConvertedState.js.map +0 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/useLatestRef.d.ts +0 -7
- package/dist/legacy-runtime/runtime-cores/assistant-transport/useLatestRef.d.ts.map +0 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/useLatestRef.js +0 -27
- package/dist/legacy-runtime/runtime-cores/assistant-transport/useLatestRef.js.map +0 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/utils.d.ts +0 -2
- package/dist/legacy-runtime/runtime-cores/assistant-transport/utils.js +0 -2
- package/src/client/InMemoryThreadList.ts +0 -241
- package/src/legacy-runtime/cloud/auiV0.ts +0 -331
- package/src/legacy-runtime/cloud/useCloudThreadListRuntime.ts +0 -31
- package/src/legacy-runtime/runtime-cores/assistant-transport/commandQueue.ts +0 -68
- package/src/legacy-runtime/runtime-cores/assistant-transport/replayBoundaryStream.test.ts +0 -430
- package/src/legacy-runtime/runtime-cores/assistant-transport/replayBoundaryStream.ts +0 -146
- package/src/legacy-runtime/runtime-cores/assistant-transport/runManager.ts +0 -113
- package/src/legacy-runtime/runtime-cores/assistant-transport/transport-delivery.test.tsx +0 -79
- package/src/legacy-runtime/runtime-cores/assistant-transport/transport-parentid.test.tsx +0 -97
- package/src/legacy-runtime/runtime-cores/assistant-transport/transport-scheduling.test.ts +0 -307
- package/src/legacy-runtime/runtime-cores/assistant-transport/types.ts +0 -170
- package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransport.spec.md +0 -141
- package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.test.tsx +0 -585
- package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.ts +0 -476
- package/src/legacy-runtime/runtime-cores/assistant-transport/useConvertedState.ts +0 -20
- package/src/legacy-runtime/runtime-cores/assistant-transport/useLatestRef.ts +0 -9
- package/src/legacy-runtime/runtime-cores/assistant-transport/utils.ts +0 -1
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { act, render, screen, waitFor } from "@testing-library/react";
|
|
4
|
+
import type { FC, PropsWithChildren } from "react";
|
|
5
|
+
import { describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { AssistantRuntimeProvider } from "../../context";
|
|
7
|
+
import { useLocalRuntime } from "../../legacy-runtime/runtime-cores/local/useLocalRuntime";
|
|
8
|
+
import type {
|
|
9
|
+
ChatModelAdapter,
|
|
10
|
+
SpeechSynthesisAdapter,
|
|
11
|
+
ThreadMessageLike,
|
|
12
|
+
} from "../../index";
|
|
13
|
+
import { ComposerPrimitiveInput } from "../composer/ComposerInput";
|
|
14
|
+
import { ThreadPrimitiveRoot } from "./ThreadRoot";
|
|
15
|
+
|
|
16
|
+
const noOpAdapter: ChatModelAdapter = {
|
|
17
|
+
async *run() {},
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const waitForAbortAdapter: ChatModelAdapter = {
|
|
21
|
+
async *run({ abortSignal }) {
|
|
22
|
+
await new Promise<void>((resolve) => {
|
|
23
|
+
if (abortSignal.aborted) {
|
|
24
|
+
resolve();
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
abortSignal.addEventListener("abort", () => resolve(), { once: true });
|
|
28
|
+
});
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const initialMessages: ThreadMessageLike[] = [
|
|
33
|
+
{
|
|
34
|
+
role: "assistant",
|
|
35
|
+
content: [{ type: "text", text: "Hello" }],
|
|
36
|
+
status: { type: "complete", reason: "stop" },
|
|
37
|
+
},
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const createSpeechAdapter = () => {
|
|
41
|
+
const cancel = vi.fn();
|
|
42
|
+
const subscribers = new Set<() => void>();
|
|
43
|
+
const utterance: SpeechSynthesisAdapter.Utterance = {
|
|
44
|
+
status: { type: "running" },
|
|
45
|
+
cancel,
|
|
46
|
+
subscribe: (callback) => {
|
|
47
|
+
subscribers.add(callback);
|
|
48
|
+
return () => subscribers.delete(callback);
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
const adapter: SpeechSynthesisAdapter = {
|
|
52
|
+
speak: vi.fn(() => utterance),
|
|
53
|
+
};
|
|
54
|
+
const finish = () => {
|
|
55
|
+
utterance.status = { type: "ended", reason: "finished" };
|
|
56
|
+
for (const subscriber of subscribers) subscriber();
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
return { adapter, cancel, finish };
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
type RuntimeRef = {
|
|
63
|
+
current: ReturnType<typeof useLocalRuntime> | null;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const RuntimeProvider: FC<
|
|
67
|
+
PropsWithChildren<{
|
|
68
|
+
runtimeRef: RuntimeRef;
|
|
69
|
+
speech: SpeechSynthesisAdapter;
|
|
70
|
+
chatModel?: ChatModelAdapter | undefined;
|
|
71
|
+
}>
|
|
72
|
+
> = ({ children, runtimeRef, speech, chatModel = noOpAdapter }) => {
|
|
73
|
+
const runtime = useLocalRuntime(chatModel, {
|
|
74
|
+
initialMessages,
|
|
75
|
+
adapters: { speech },
|
|
76
|
+
});
|
|
77
|
+
runtimeRef.current = runtime;
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<AssistantRuntimeProvider runtime={runtime}>
|
|
81
|
+
{children}
|
|
82
|
+
</AssistantRuntimeProvider>
|
|
83
|
+
);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const startSpeaking = (runtimeRef: RuntimeRef) => {
|
|
87
|
+
act(() => {
|
|
88
|
+
runtimeRef.current!.thread.getMessageByIndex(0).speak();
|
|
89
|
+
});
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const dispatchEscape = (target: EventTarget) => {
|
|
93
|
+
const event = new KeyboardEvent("keydown", {
|
|
94
|
+
key: "Escape",
|
|
95
|
+
bubbles: true,
|
|
96
|
+
cancelable: true,
|
|
97
|
+
});
|
|
98
|
+
act(() => target.dispatchEvent(event));
|
|
99
|
+
return event;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
describe("ThreadPrimitiveRoot", () => {
|
|
103
|
+
it("stops active speech after the initiating control unmounts", async () => {
|
|
104
|
+
const speech = createSpeechAdapter();
|
|
105
|
+
const runtimeRef: RuntimeRef = { current: null };
|
|
106
|
+
const App = ({ showControl }: { showControl: boolean }) => (
|
|
107
|
+
<RuntimeProvider runtimeRef={runtimeRef} speech={speech.adapter}>
|
|
108
|
+
<ThreadPrimitiveRoot>
|
|
109
|
+
{showControl && <button data-testid="speak-control" />}
|
|
110
|
+
</ThreadPrimitiveRoot>
|
|
111
|
+
</RuntimeProvider>
|
|
112
|
+
);
|
|
113
|
+
const view = render(<App showControl />);
|
|
114
|
+
|
|
115
|
+
const control = screen.getByTestId("speak-control");
|
|
116
|
+
control.focus();
|
|
117
|
+
startSpeaking(runtimeRef);
|
|
118
|
+
await waitFor(() => {
|
|
119
|
+
expect(runtimeRef.current!.thread.getState().speech).toBeDefined();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
view.rerender(<App showControl={false} />);
|
|
123
|
+
const event = dispatchEscape(document.body);
|
|
124
|
+
|
|
125
|
+
expect(speech.cancel).toHaveBeenCalledOnce();
|
|
126
|
+
expect(event.defaultPrevented).toBe(true);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("does not consume Escape when no speech is active", () => {
|
|
130
|
+
const speech = createSpeechAdapter();
|
|
131
|
+
const runtimeRef: RuntimeRef = { current: null };
|
|
132
|
+
render(
|
|
133
|
+
<RuntimeProvider runtimeRef={runtimeRef} speech={speech.adapter}>
|
|
134
|
+
<ThreadPrimitiveRoot>
|
|
135
|
+
<button data-testid="thread-control" />
|
|
136
|
+
</ThreadPrimitiveRoot>
|
|
137
|
+
</RuntimeProvider>,
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const event = dispatchEscape(screen.getByTestId("thread-control"));
|
|
141
|
+
|
|
142
|
+
expect(speech.cancel).not.toHaveBeenCalled();
|
|
143
|
+
expect(event.defaultPrevented).toBe(false);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("stops speech from outside the only mounted thread", async () => {
|
|
147
|
+
const speech = createSpeechAdapter();
|
|
148
|
+
const runtimeRef: RuntimeRef = { current: null };
|
|
149
|
+
render(
|
|
150
|
+
<>
|
|
151
|
+
<RuntimeProvider runtimeRef={runtimeRef} speech={speech.adapter}>
|
|
152
|
+
<ThreadPrimitiveRoot />
|
|
153
|
+
</RuntimeProvider>
|
|
154
|
+
<button data-testid="outside-control" />
|
|
155
|
+
</>,
|
|
156
|
+
);
|
|
157
|
+
startSpeaking(runtimeRef);
|
|
158
|
+
await waitFor(() => {
|
|
159
|
+
expect(runtimeRef.current!.thread.getState().speech).toBeDefined();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const event = dispatchEscape(screen.getByTestId("outside-control"));
|
|
163
|
+
|
|
164
|
+
expect(speech.cancel).toHaveBeenCalledOnce();
|
|
165
|
+
expect(event.defaultPrevented).toBe(true);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("ignores speech ending between the rendered state and Escape", async () => {
|
|
169
|
+
const speech = createSpeechAdapter();
|
|
170
|
+
const runtimeRef: RuntimeRef = { current: null };
|
|
171
|
+
render(
|
|
172
|
+
<RuntimeProvider runtimeRef={runtimeRef} speech={speech.adapter}>
|
|
173
|
+
<ThreadPrimitiveRoot />
|
|
174
|
+
</RuntimeProvider>,
|
|
175
|
+
);
|
|
176
|
+
startSpeaking(runtimeRef);
|
|
177
|
+
await waitFor(() => {
|
|
178
|
+
expect(runtimeRef.current!.thread.getState().speech).toBeDefined();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
expect(() => {
|
|
182
|
+
act(() => {
|
|
183
|
+
speech.finish();
|
|
184
|
+
document.body.dispatchEvent(
|
|
185
|
+
new KeyboardEvent("keydown", {
|
|
186
|
+
key: "Escape",
|
|
187
|
+
bubbles: true,
|
|
188
|
+
cancelable: true,
|
|
189
|
+
}),
|
|
190
|
+
);
|
|
191
|
+
});
|
|
192
|
+
}).not.toThrow();
|
|
193
|
+
expect(speech.cancel).not.toHaveBeenCalled();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("lets a composer mounted after the root consume Escape first", async () => {
|
|
197
|
+
const speech = createSpeechAdapter();
|
|
198
|
+
const runtimeRef: RuntimeRef = { current: null };
|
|
199
|
+
const App = ({ showComposer }: { showComposer: boolean }) => (
|
|
200
|
+
<RuntimeProvider
|
|
201
|
+
runtimeRef={runtimeRef}
|
|
202
|
+
speech={speech.adapter}
|
|
203
|
+
chatModel={waitForAbortAdapter}
|
|
204
|
+
>
|
|
205
|
+
<ThreadPrimitiveRoot>
|
|
206
|
+
{showComposer && <ComposerPrimitiveInput data-testid="composer" />}
|
|
207
|
+
</ThreadPrimitiveRoot>
|
|
208
|
+
</RuntimeProvider>
|
|
209
|
+
);
|
|
210
|
+
const view = render(<App showComposer={false} />);
|
|
211
|
+
startSpeaking(runtimeRef);
|
|
212
|
+
act(() => {
|
|
213
|
+
runtimeRef.current!.thread.append("Run");
|
|
214
|
+
});
|
|
215
|
+
await waitFor(() => {
|
|
216
|
+
expect(runtimeRef.current!.thread.getState().speech).toBeDefined();
|
|
217
|
+
expect(runtimeRef.current!.thread.getState().isRunning).toBe(true);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
view.rerender(<App showComposer />);
|
|
221
|
+
const event = dispatchEscape(screen.getByTestId("composer"));
|
|
222
|
+
|
|
223
|
+
await waitFor(() => {
|
|
224
|
+
expect(runtimeRef.current!.thread.getState().isRunning).toBe(false);
|
|
225
|
+
});
|
|
226
|
+
expect(speech.cancel).not.toHaveBeenCalled();
|
|
227
|
+
expect(event.defaultPrevented).toBe(true);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("lets an idle composer fall through so Escape still stops speech", async () => {
|
|
231
|
+
const speech = createSpeechAdapter();
|
|
232
|
+
const runtimeRef: RuntimeRef = { current: null };
|
|
233
|
+
render(
|
|
234
|
+
<RuntimeProvider runtimeRef={runtimeRef} speech={speech.adapter}>
|
|
235
|
+
<ThreadPrimitiveRoot>
|
|
236
|
+
<ComposerPrimitiveInput data-testid="composer" />
|
|
237
|
+
</ThreadPrimitiveRoot>
|
|
238
|
+
</RuntimeProvider>,
|
|
239
|
+
);
|
|
240
|
+
startSpeaking(runtimeRef);
|
|
241
|
+
await waitFor(() => {
|
|
242
|
+
expect(runtimeRef.current!.thread.getState().speech).toBeDefined();
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
const event = dispatchEscape(screen.getByTestId("composer"));
|
|
246
|
+
|
|
247
|
+
expect(speech.cancel).toHaveBeenCalledOnce();
|
|
248
|
+
expect(event.defaultPrevented).toBe(true);
|
|
249
|
+
expect(runtimeRef.current!.thread.getState().isRunning).toBe(false);
|
|
250
|
+
});
|
|
251
|
+
});
|
|
@@ -5,7 +5,9 @@ import {
|
|
|
5
5
|
type ComponentRef,
|
|
6
6
|
forwardRef,
|
|
7
7
|
type ComponentPropsWithoutRef,
|
|
8
|
+
useEffect,
|
|
8
9
|
} from "react";
|
|
10
|
+
import { useAui } from "@assistant-ui/store";
|
|
9
11
|
|
|
10
12
|
export namespace ThreadPrimitiveRoot {
|
|
11
13
|
export type Element = ComponentRef<typeof Primitive.div>;
|
|
@@ -37,6 +39,34 @@ export const ThreadPrimitiveRoot = forwardRef<
|
|
|
37
39
|
ThreadPrimitiveRoot.Element,
|
|
38
40
|
ThreadPrimitiveRoot.Props
|
|
39
41
|
>((props, ref) => {
|
|
42
|
+
const aui = useAui();
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
46
|
+
if (event.key !== "Escape") return;
|
|
47
|
+
if (event.defaultPrevented || aui.thread.source === null) return;
|
|
48
|
+
if (aui.thread.getState().speech == null) return;
|
|
49
|
+
event.preventDefault();
|
|
50
|
+
try {
|
|
51
|
+
aui.thread.stopSpeaking();
|
|
52
|
+
} catch (error) {
|
|
53
|
+
// getState() is the last rendered snapshot, so speech can finish before this call.
|
|
54
|
+
if (
|
|
55
|
+
!(error instanceof Error) ||
|
|
56
|
+
error.message !== "No message is being spoken"
|
|
57
|
+
) {
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// Bubble phase lets capture-phase Escape handlers consume the event first.
|
|
64
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
65
|
+
return () => {
|
|
66
|
+
document.removeEventListener("keydown", handleKeyDown);
|
|
67
|
+
};
|
|
68
|
+
}, [aui]);
|
|
69
|
+
|
|
40
70
|
return <Primitive.div {...props} ref={ref} />;
|
|
41
71
|
});
|
|
42
72
|
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
useLayoutEffect,
|
|
11
11
|
useMemo,
|
|
12
12
|
} from "react";
|
|
13
|
-
import {
|
|
13
|
+
import { useAuiState } from "@assistant-ui/store";
|
|
14
14
|
import { useManagedRef } from "../../utils/hooks/useManagedRef";
|
|
15
15
|
import { useThreadViewportAutoScroll } from "./useThreadViewportAutoScroll";
|
|
16
16
|
import { ThreadPrimitiveViewportProvider } from "../../context/providers/ThreadViewportProvider";
|
|
@@ -144,13 +144,6 @@ const useTopAnchorTurn = (enabled: boolean) => {
|
|
|
144
144
|
|
|
145
145
|
state.setTopAnchorTurn(activeTurn);
|
|
146
146
|
}, [activeTurn, threadViewportStore]);
|
|
147
|
-
|
|
148
|
-
const clearTopAnchorTurn = useCallback(() => {
|
|
149
|
-
threadViewportStore.getState().setTopAnchorTurn(null);
|
|
150
|
-
}, [threadViewportStore]);
|
|
151
|
-
|
|
152
|
-
useAuiEvent("thread.initialize", clearTopAnchorTurn);
|
|
153
|
-
useAuiEvent("threadListItem.switchedTo", clearTopAnchorTurn);
|
|
154
147
|
};
|
|
155
148
|
|
|
156
149
|
const ThreadPrimitiveViewportScrollable = forwardRef<
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
import { useEffect, useState, type FC, type PropsWithChildren } from "react";
|
|
14
14
|
import { useAuiState } from "@assistant-ui/store";
|
|
15
15
|
import { AssistantRuntimeProvider } from "../../context";
|
|
16
|
+
import { useThreadViewport } from "../../context/react/ThreadViewportContext";
|
|
16
17
|
import * as MessagePrimitive from "../message";
|
|
17
18
|
import { ThreadPrimitiveMessages } from "./ThreadMessages";
|
|
18
19
|
import { ThreadPrimitiveRoot } from "./ThreadRoot";
|
|
@@ -152,6 +153,11 @@ const Message: FC = () => (
|
|
|
152
153
|
</MessagePrimitive.Root>
|
|
153
154
|
);
|
|
154
155
|
|
|
156
|
+
const AtBottom: FC = () => {
|
|
157
|
+
const isAtBottom = useThreadViewport((s) => s.isAtBottom);
|
|
158
|
+
return <output data-testid="is-at-bottom">{String(isAtBottom)}</output>;
|
|
159
|
+
};
|
|
160
|
+
|
|
155
161
|
const Thread = ({
|
|
156
162
|
autoScroll,
|
|
157
163
|
scrollToBottomOnInitialize,
|
|
@@ -167,6 +173,7 @@ const Thread = ({
|
|
|
167
173
|
scrollToBottomOnInitialize={scrollToBottomOnInitialize}
|
|
168
174
|
>
|
|
169
175
|
<ThreadPrimitiveMessages components={{ Message }} />
|
|
176
|
+
<AtBottom />
|
|
170
177
|
</ThreadPrimitiveViewport>
|
|
171
178
|
</ThreadPrimitiveRoot>
|
|
172
179
|
);
|
|
@@ -175,6 +182,7 @@ const BottomAnchorThread = () => (
|
|
|
175
182
|
<ThreadPrimitiveRoot>
|
|
176
183
|
<ThreadPrimitiveViewport data-testid="viewport">
|
|
177
184
|
<ThreadPrimitiveMessages components={{ Message }} />
|
|
185
|
+
<AtBottom />
|
|
178
186
|
</ThreadPrimitiveViewport>
|
|
179
187
|
</ThreadPrimitiveRoot>
|
|
180
188
|
);
|
|
@@ -312,6 +320,147 @@ describe("useThreadViewportAutoScroll", () => {
|
|
|
312
320
|
scrollToSpy.mockRestore();
|
|
313
321
|
});
|
|
314
322
|
|
|
323
|
+
it("keeps following after a content-growth burst undershoots the bottom", async () => {
|
|
324
|
+
render(
|
|
325
|
+
<SyncRuntimeProvider>
|
|
326
|
+
<BottomAnchorThread />
|
|
327
|
+
</SyncRuntimeProvider>,
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
const viewport = getViewport();
|
|
331
|
+
await waitFor(() => {
|
|
332
|
+
expect(screen.getAllByTestId("thread-message")).toHaveLength(
|
|
333
|
+
messages.length,
|
|
334
|
+
);
|
|
335
|
+
expect(viewport.scrollTop).toBe(getMaxScrollTop(viewport));
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
const scrollTopBeforeBurst = viewport.scrollTop;
|
|
339
|
+
viewportMeasurementOffset += 164;
|
|
340
|
+
act(() => {
|
|
341
|
+
viewport.scrollTop = scrollTopBeforeBurst + 106;
|
|
342
|
+
viewport.dispatchEvent(new Event("scroll"));
|
|
343
|
+
viewport.dispatchEvent(new Event("scroll"));
|
|
344
|
+
});
|
|
345
|
+
expect(viewport.scrollTop).toBeLessThan(getMaxScrollTop(viewport));
|
|
346
|
+
|
|
347
|
+
viewportMeasurementOffset += 200;
|
|
348
|
+
act(notifyResizeObservers);
|
|
349
|
+
|
|
350
|
+
expect(viewport.scrollTop).toBe(getMaxScrollTop(viewport));
|
|
351
|
+
expect(screen.getByTestId("is-at-bottom").textContent).toBe("true");
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it("keeps following after a pointerdown that does not scroll the viewport", async () => {
|
|
355
|
+
render(
|
|
356
|
+
<SyncRuntimeProvider>
|
|
357
|
+
<BottomAnchorThread />
|
|
358
|
+
</SyncRuntimeProvider>,
|
|
359
|
+
);
|
|
360
|
+
|
|
361
|
+
const viewport = getViewport();
|
|
362
|
+
await waitFor(() => {
|
|
363
|
+
expect(viewport.scrollTop).toBe(getMaxScrollTop(viewport));
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
act(() => {
|
|
367
|
+
viewport.dispatchEvent(new Event("pointerdown"));
|
|
368
|
+
});
|
|
369
|
+
viewportMeasurementOffset += 200;
|
|
370
|
+
act(notifyResizeObservers);
|
|
371
|
+
|
|
372
|
+
expect(viewport.scrollTop).toBe(getMaxScrollTop(viewport));
|
|
373
|
+
expect(screen.getByTestId("is-at-bottom").textContent).toBe("true");
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("cancels a queued bottom scroll when the user scrolls up", async () => {
|
|
377
|
+
let nextFrameId = 0;
|
|
378
|
+
let pendingFrame: {
|
|
379
|
+
id: number;
|
|
380
|
+
callback: FrameRequestCallback;
|
|
381
|
+
} | null = null;
|
|
382
|
+
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
|
|
383
|
+
const originalCancelAnimationFrame = globalThis.cancelAnimationFrame;
|
|
384
|
+
|
|
385
|
+
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
|
|
386
|
+
const id = ++nextFrameId;
|
|
387
|
+
pendingFrame = { id, callback };
|
|
388
|
+
return id;
|
|
389
|
+
});
|
|
390
|
+
const cancelAnimationFrame = vi.fn((id: number) => {
|
|
391
|
+
if (pendingFrame?.id === id) pendingFrame = null;
|
|
392
|
+
});
|
|
393
|
+
vi.stubGlobal("cancelAnimationFrame", cancelAnimationFrame);
|
|
394
|
+
|
|
395
|
+
try {
|
|
396
|
+
render(
|
|
397
|
+
<SyncRuntimeProvider>
|
|
398
|
+
<BottomAnchorThread />
|
|
399
|
+
</SyncRuntimeProvider>,
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
const viewport = getViewport();
|
|
403
|
+
await waitFor(() => {
|
|
404
|
+
expect(screen.getAllByTestId("thread-message")).toHaveLength(
|
|
405
|
+
messages.length,
|
|
406
|
+
);
|
|
407
|
+
expect(pendingFrame).not.toBeNull();
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
let scrollTopAfterLeave = 0;
|
|
411
|
+
act(() => {
|
|
412
|
+
viewport.scrollTop = getMaxScrollTop(viewport);
|
|
413
|
+
viewport.dispatchEvent(new Event("scroll"));
|
|
414
|
+
viewport.scrollTop -= 80;
|
|
415
|
+
scrollTopAfterLeave = viewport.scrollTop;
|
|
416
|
+
viewport.dispatchEvent(new Event("scroll"));
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
const frame = pendingFrame as {
|
|
420
|
+
id: number;
|
|
421
|
+
callback: FrameRequestCallback;
|
|
422
|
+
} | null;
|
|
423
|
+
if (frame) {
|
|
424
|
+
act(() => {
|
|
425
|
+
frame.callback(performance.now());
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
expect(viewport.scrollTop).toBe(scrollTopAfterLeave);
|
|
430
|
+
expect(screen.getByTestId("is-at-bottom").textContent).toBe("false");
|
|
431
|
+
expect(cancelAnimationFrame).toHaveBeenCalledWith(expect.any(Number));
|
|
432
|
+
} finally {
|
|
433
|
+
vi.stubGlobal("requestAnimationFrame", originalRequestAnimationFrame);
|
|
434
|
+
vi.stubGlobal("cancelAnimationFrame", originalCancelAnimationFrame);
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it("does not resume bottom follow after a stable-height user scroll-up", async () => {
|
|
439
|
+
render(
|
|
440
|
+
<SyncRuntimeProvider>
|
|
441
|
+
<BottomAnchorThread />
|
|
442
|
+
</SyncRuntimeProvider>,
|
|
443
|
+
);
|
|
444
|
+
|
|
445
|
+
const viewport = getViewport();
|
|
446
|
+
await waitFor(() => {
|
|
447
|
+
expect(viewport.scrollTop).toBe(getMaxScrollTop(viewport));
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
act(() => {
|
|
451
|
+
viewport.scrollTop = viewport.scrollTop - 80;
|
|
452
|
+
viewport.dispatchEvent(new Event("scroll"));
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
const scrollTopAfterLeave = viewport.scrollTop;
|
|
456
|
+
viewportMeasurementOffset += 200;
|
|
457
|
+
act(notifyResizeObservers);
|
|
458
|
+
|
|
459
|
+
expect(viewport.scrollTop).toBe(scrollTopAfterLeave);
|
|
460
|
+
expect(viewport.scrollTop).toBeLessThan(getMaxScrollTop(viewport));
|
|
461
|
+
expect(screen.getByTestId("is-at-bottom").textContent).toBe("false");
|
|
462
|
+
});
|
|
463
|
+
|
|
315
464
|
it("defers auto-scroll to an active top anchor only while the run is active", async () => {
|
|
316
465
|
let releaseRun!: () => void;
|
|
317
466
|
const runGate = new Promise<void>((resolve) => {
|
|
@@ -401,5 +550,8 @@ describe("useThreadViewportAutoScroll", () => {
|
|
|
401
550
|
});
|
|
402
551
|
|
|
403
552
|
expect(getViewport().scrollTop).toBe(0);
|
|
553
|
+
viewportMeasurementOffset += 200;
|
|
554
|
+
act(notifyResizeObservers);
|
|
555
|
+
expect(screen.getByTestId("is-at-bottom").textContent).toBe("false");
|
|
404
556
|
});
|
|
405
557
|
});
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { useComposedRefs } from "@radix-ui/react-compose-refs";
|
|
4
4
|
import { useCallback, useLayoutEffect, useRef, type RefCallback } from "react";
|
|
5
5
|
import { useAuiEvent, useAuiState } from "@assistant-ui/store";
|
|
6
|
+
import { isUserScrollUp } from "@assistant-ui/store/client";
|
|
6
7
|
import { useOnResizeContent } from "../../utils/hooks/useOnResizeContent";
|
|
7
8
|
import { useOnScrollToBottom } from "../../utils/hooks/useOnScrollToBottom";
|
|
8
9
|
import { useManagedRef } from "../../utils/hooks/useManagedRef";
|
|
@@ -68,37 +69,36 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
|
|
|
68
69
|
// triggers, cleared when handleScroll confirms we reached bottom, or when the
|
|
69
70
|
// user actively scrolls up while content size is stable.
|
|
70
71
|
const scrollingToBottomBehaviorRef = useRef<ScrollBehavior | null>(null);
|
|
72
|
+
const followBottomRef = useRef(autoScroll);
|
|
71
73
|
|
|
72
74
|
const scrollToBottom = useCallback((behavior: ScrollBehavior) => {
|
|
73
75
|
const div = divRef.current;
|
|
74
76
|
if (!div) return;
|
|
75
77
|
|
|
78
|
+
followBottomRef.current = true;
|
|
76
79
|
scrollingToBottomBehaviorRef.current = behavior;
|
|
77
80
|
div.scrollTo({ top: div.scrollHeight, behavior });
|
|
78
81
|
}, []);
|
|
79
82
|
|
|
83
|
+
const cancelScheduledFrame = useCallback(() => {
|
|
84
|
+
if (scheduledFrameRef.current === null) return;
|
|
85
|
+
cancelAnimationFrame(scheduledFrameRef.current);
|
|
86
|
+
scheduledFrameRef.current = null;
|
|
87
|
+
}, []);
|
|
88
|
+
|
|
80
89
|
const scheduleScrollToBottom = useCallback(
|
|
81
90
|
(behavior: ScrollBehavior) => {
|
|
82
91
|
scrollingToBottomBehaviorRef.current = behavior;
|
|
83
|
-
|
|
84
|
-
cancelAnimationFrame(scheduledFrameRef.current);
|
|
85
|
-
}
|
|
92
|
+
cancelScheduledFrame();
|
|
86
93
|
scheduledFrameRef.current = requestAnimationFrame(() => {
|
|
87
94
|
scheduledFrameRef.current = null;
|
|
88
95
|
scrollToBottom(behavior);
|
|
89
96
|
});
|
|
90
97
|
},
|
|
91
|
-
[scrollToBottom],
|
|
98
|
+
[cancelScheduledFrame, scrollToBottom],
|
|
92
99
|
);
|
|
93
100
|
|
|
94
|
-
useLayoutEffect(
|
|
95
|
-
() => () => {
|
|
96
|
-
if (scheduledFrameRef.current !== null) {
|
|
97
|
-
cancelAnimationFrame(scheduledFrameRef.current);
|
|
98
|
-
}
|
|
99
|
-
},
|
|
100
|
-
[],
|
|
101
|
-
);
|
|
101
|
+
useLayoutEffect(() => () => cancelScheduledFrame(), [cancelScheduledFrame]);
|
|
102
102
|
|
|
103
103
|
const hasActiveTopAnchor = useCallback(() => {
|
|
104
104
|
const state = threadViewportStore.getState();
|
|
@@ -124,6 +124,14 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
|
|
|
124
124
|
// no-op: a smooth scroll-to-bottom fires many midpoint scroll events
|
|
125
125
|
// before landing, don't flicker isAtBottom or clear intent mid-animation
|
|
126
126
|
} else {
|
|
127
|
+
const userScrolledUp = isUserScrollUp(
|
|
128
|
+
{
|
|
129
|
+
scrollTop: lastScrollTop.current,
|
|
130
|
+
scrollHeight: lastScrollHeight.current,
|
|
131
|
+
},
|
|
132
|
+
div,
|
|
133
|
+
);
|
|
134
|
+
|
|
127
135
|
if (newIsAtBottom) {
|
|
128
136
|
// newIsAtBottom is ambiguous when the viewport doesn't overflow —
|
|
129
137
|
// keep intent alive until content can actually scroll
|
|
@@ -131,12 +139,11 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
|
|
|
131
139
|
if (viewportOverflows) {
|
|
132
140
|
scrollingToBottomBehaviorRef.current = null;
|
|
133
141
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
) {
|
|
138
|
-
// scrollHeight equality rules out content-driven shifts being misread as user scroll-up
|
|
142
|
+
if (autoScroll) followBottomRef.current = true;
|
|
143
|
+
} else if (userScrolledUp) {
|
|
144
|
+
cancelScheduledFrame();
|
|
139
145
|
scrollingToBottomBehaviorRef.current = null;
|
|
146
|
+
followBottomRef.current = false;
|
|
140
147
|
}
|
|
141
148
|
|
|
142
149
|
const shouldUpdate =
|
|
@@ -176,7 +183,7 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
|
|
|
176
183
|
} else if (
|
|
177
184
|
autoScroll &&
|
|
178
185
|
!(isRunning && hasActiveTopAnchor()) &&
|
|
179
|
-
|
|
186
|
+
followBottomRef.current
|
|
180
187
|
) {
|
|
181
188
|
scrollToBottom("instant");
|
|
182
189
|
}
|
|
@@ -224,7 +231,7 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
|
|
|
224
231
|
scheduleScrollToBottom("auto");
|
|
225
232
|
});
|
|
226
233
|
|
|
227
|
-
useAuiEvent("
|
|
234
|
+
useAuiEvent("threads.selectionChanged", () => {
|
|
228
235
|
if (!scrollToBottomOnThreadSwitch) return;
|
|
229
236
|
scheduleScrollToBottom("instant");
|
|
230
237
|
});
|
|
@@ -228,4 +228,33 @@ describe("SandboxHost", () => {
|
|
|
228
228
|
expect(onError).toHaveBeenCalledTimes(1);
|
|
229
229
|
expect(onError.mock.calls[0]![0].message).toBe("boom");
|
|
230
230
|
});
|
|
231
|
+
|
|
232
|
+
it("disposes the rendered frame when bridge creation fails", async () => {
|
|
233
|
+
const rendered = fakeRendered();
|
|
234
|
+
renderHtmlMock.mockResolvedValue(rendered);
|
|
235
|
+
const error = new Error("bridge failed");
|
|
236
|
+
const onError = vi.fn();
|
|
237
|
+
|
|
238
|
+
await act(async () => {
|
|
239
|
+
root.render(
|
|
240
|
+
<SandboxHost
|
|
241
|
+
content={{ html: "" }}
|
|
242
|
+
contentKey="k"
|
|
243
|
+
createBridge={() => {
|
|
244
|
+
throw error;
|
|
245
|
+
}}
|
|
246
|
+
onError={onError}
|
|
247
|
+
/>,
|
|
248
|
+
);
|
|
249
|
+
});
|
|
250
|
+
await flush();
|
|
251
|
+
|
|
252
|
+
expect(onError).toHaveBeenCalledWith(error);
|
|
253
|
+
expect(rendered.dispose).toHaveBeenCalledTimes(1);
|
|
254
|
+
|
|
255
|
+
await act(async () => {
|
|
256
|
+
root.unmount();
|
|
257
|
+
});
|
|
258
|
+
expect(rendered.dispose).toHaveBeenCalledTimes(1);
|
|
259
|
+
});
|
|
231
260
|
});
|