@assistant-ui/react 0.15.14 → 0.15.16

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 (210) hide show
  1. package/dist/assistant-transport.d.ts +32 -0
  2. package/dist/assistant-transport.d.ts.map +1 -0
  3. package/dist/assistant-transport.js +27 -0
  4. package/dist/assistant-transport.js.map +1 -0
  5. package/dist/context/providers/MessageProvider.js +1 -1
  6. package/dist/devtools/DevToolsHooks.d.ts +0 -2
  7. package/dist/devtools/DevToolsHooks.d.ts.map +1 -1
  8. package/dist/devtools/DevToolsHooks.js +10 -12
  9. package/dist/devtools/DevToolsHooks.js.map +1 -1
  10. package/dist/hooks/useMessageQuote.js +1 -1
  11. package/dist/hooks/useToolCallElapsed.js +1 -1
  12. package/dist/index.d.ts +4 -7
  13. package/dist/index.js +6 -8
  14. package/dist/legacy-runtime/AssistantRuntimeProvider.js +1 -1
  15. package/dist/mcp-apps/McpAppRenderer.js +1 -1
  16. package/dist/mcp-apps/McpAppsRemoteHost.d.ts.map +1 -1
  17. package/dist/mcp-apps/McpAppsRemoteHost.js +18 -2
  18. package/dist/mcp-apps/McpAppsRemoteHost.js.map +1 -1
  19. package/dist/mcp-apps/bridge.d.ts.map +1 -1
  20. package/dist/mcp-apps/bridge.js +33 -24
  21. package/dist/mcp-apps/bridge.js.map +1 -1
  22. package/dist/model-context/makeAssistantVisible.js +1 -1
  23. package/dist/primitives/actionBar/ActionBarCopy.js +1 -1
  24. package/dist/primitives/actionBar/ActionBarExportMarkdown.d.ts.map +1 -1
  25. package/dist/primitives/actionBar/ActionBarExportMarkdown.js +5 -2
  26. package/dist/primitives/actionBar/ActionBarExportMarkdown.js.map +1 -1
  27. package/dist/primitives/actionBar/ActionBarFeedbackNegative.js +1 -1
  28. package/dist/primitives/actionBar/ActionBarFeedbackPositive.js +1 -1
  29. package/dist/primitives/actionBar/ActionBarStopSpeaking.d.ts.map +1 -1
  30. package/dist/primitives/actionBar/ActionBarStopSpeaking.js +24 -37
  31. package/dist/primitives/actionBar/ActionBarStopSpeaking.js.map +1 -1
  32. package/dist/primitives/actionBar/useActionBarFloatStatus.js +1 -1
  33. package/dist/primitives/assistantModal/AssistantModalRoot.js +1 -1
  34. package/dist/primitives/attachment/AttachmentName.js +1 -1
  35. package/dist/primitives/attachment/AttachmentRemove.js +1 -1
  36. package/dist/primitives/attachment/AttachmentThumb.js +1 -1
  37. package/dist/primitives/branchPicker/BranchPickerCount.js +1 -1
  38. package/dist/primitives/branchPicker/BranchPickerNumber.js +1 -1
  39. package/dist/primitives/branchPicker/BranchPickerRoot.d.ts.map +1 -1
  40. package/dist/primitives/branchPicker/BranchPickerRoot.js +20 -15
  41. package/dist/primitives/branchPicker/BranchPickerRoot.js.map +1 -1
  42. package/dist/primitives/chainOfThought/ChainOfThoughtAccordionTrigger.js +1 -1
  43. package/dist/primitives/composer/ComposerAddAttachment.js +1 -1
  44. package/dist/primitives/composer/ComposerAttachmentDropzone.js +1 -1
  45. package/dist/primitives/composer/ComposerDictationTranscript.d.ts +2 -2
  46. package/dist/primitives/composer/ComposerDictationTranscript.js +3 -3
  47. package/dist/primitives/composer/ComposerDictationTranscript.js.map +1 -1
  48. package/dist/primitives/composer/ComposerInput.js +2 -2
  49. package/dist/primitives/composer/ComposerInput.js.map +1 -1
  50. package/dist/primitives/composer/ComposerQuote.js +1 -1
  51. package/dist/primitives/composer/ComposerRoot.js +1 -1
  52. package/dist/primitives/composer/ComposerStopDictation.js +1 -1
  53. package/dist/primitives/composer/trigger/TriggerPopover.js +1 -1
  54. package/dist/primitives/composer/trigger/triggerSelectionResource.d.ts.map +1 -1
  55. package/dist/primitives/composer/trigger/triggerSelectionResource.js +24 -20
  56. package/dist/primitives/composer/trigger/triggerSelectionResource.js.map +1 -1
  57. package/dist/primitives/composer/useComposerInputState.js +1 -1
  58. package/dist/primitives/message/MessageIf.js +1 -1
  59. package/dist/primitives/message/MessagePartsGrouped.js +1 -1
  60. package/dist/primitives/message/MessageRoot.js +1 -1
  61. package/dist/primitives/message.js +1 -1
  62. package/dist/primitives/queueItem/QueueItemRemove.js +1 -1
  63. package/dist/primitives/queueItem/QueueItemSteer.js +1 -1
  64. package/dist/primitives/queueItem/QueueItemText.js +1 -1
  65. package/dist/primitives/selectionToolbar/SelectionToolbarQuote.js +1 -1
  66. package/dist/primitives/suggestion/SuggestionDescription.js +1 -1
  67. package/dist/primitives/suggestion/SuggestionTitle.js +1 -1
  68. package/dist/primitives/suggestion/SuggestionTrigger.js +1 -1
  69. package/dist/primitives/thread/ThreadIf.js +1 -1
  70. package/dist/primitives/thread/ThreadRoot.d.ts +3 -0
  71. package/dist/primitives/thread/ThreadRoot.d.ts.map +1 -1
  72. package/dist/primitives/thread/ThreadRoot.js +44 -9
  73. package/dist/primitives/thread/ThreadRoot.js.map +1 -1
  74. package/dist/primitives/thread/ThreadViewport.d.ts.map +1 -1
  75. package/dist/primitives/thread/ThreadViewport.js +2 -13
  76. package/dist/primitives/thread/ThreadViewport.js.map +1 -1
  77. package/dist/primitives/thread/useThreadViewportAutoScroll.d.ts.map +1 -1
  78. package/dist/primitives/thread/useThreadViewportAutoScroll.js +23 -8
  79. package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
  80. package/dist/primitives/threadList/ThreadListNew.js +1 -1
  81. package/dist/sandbox-host/SandboxHost.d.ts.map +1 -1
  82. package/dist/sandbox-host/SandboxHost.js +6 -1
  83. package/dist/sandbox-host/SandboxHost.js.map +1 -1
  84. package/dist/unstable/useComposerInput.d.ts +2 -2
  85. package/dist/unstable/useComposerInput.js +1 -1
  86. package/dist/unstable/useComposerInput.js.map +1 -1
  87. package/dist/unstable/useComposerInputHistory.js +2 -2
  88. package/dist/unstable/useComposerInputHistory.js.map +1 -1
  89. package/dist/unstable/useLiveCompletionAdapter.d.ts +5 -0
  90. package/dist/unstable/useLiveCompletionAdapter.d.ts.map +1 -1
  91. package/dist/unstable/useLiveCompletionAdapter.js +16 -3
  92. package/dist/unstable/useLiveCompletionAdapter.js.map +1 -1
  93. package/dist/unstable/useMentionAdapter.d.ts +1 -1
  94. package/dist/utils/invokeCallbackSafely.d.ts +5 -0
  95. package/dist/utils/invokeCallbackSafely.d.ts.map +1 -0
  96. package/dist/utils/invokeCallbackSafely.js +15 -0
  97. package/dist/utils/invokeCallbackSafely.js.map +1 -0
  98. package/dist/utils/smooth/SmoothContext.d.ts +6 -6
  99. package/dist/utils/smooth/SmoothContext.js +1 -1
  100. package/dist/utils/smooth/useSmooth.d.ts +3 -0
  101. package/dist/utils/smooth/useSmooth.d.ts.map +1 -1
  102. package/dist/utils/smooth/useSmooth.js +16 -1
  103. package/dist/utils/smooth/useSmooth.js.map +1 -1
  104. package/dist/utils/useToolArgsFieldStatus.js +1 -1
  105. package/package.json +10 -10
  106. package/src/assistant-transport.ts +94 -0
  107. package/src/devtools/DevToolsHooks.test.ts +40 -0
  108. package/src/devtools/DevToolsHooks.ts +10 -15
  109. package/src/index.ts +8 -4
  110. package/src/mcp-apps/McpAppsRemoteHost.test.ts +29 -3
  111. package/src/mcp-apps/McpAppsRemoteHost.ts +42 -7
  112. package/src/mcp-apps/bridge.test.ts +153 -0
  113. package/src/mcp-apps/bridge.ts +42 -30
  114. package/src/model-context/frame/SPEC_AssistantFrame.md +1 -1
  115. package/src/primitives/actionBar/ActionBarExportMarkdown.test.tsx +67 -0
  116. package/src/primitives/actionBar/ActionBarExportMarkdown.tsx +3 -1
  117. package/src/primitives/actionBar/ActionBarStopSpeaking.tsx +0 -9
  118. package/src/primitives/branchPicker/BranchPickerRoot.tsx +5 -3
  119. package/src/primitives/composer/ComposerDictationTranscript.tsx +2 -2
  120. package/src/primitives/composer/ComposerInput.tsx +1 -1
  121. package/src/primitives/composer/trigger/triggerSelectionResource.test.ts +135 -0
  122. package/src/primitives/composer/trigger/triggerSelectionResource.ts +2 -0
  123. package/src/primitives/thread/ThreadRoot.test.tsx +251 -0
  124. package/src/primitives/thread/ThreadRoot.tsx +33 -0
  125. package/src/primitives/thread/ThreadViewport.tsx +1 -8
  126. package/src/primitives/thread/useThreadViewportAutoScroll.test.tsx +152 -0
  127. package/src/primitives/thread/useThreadViewportAutoScroll.ts +26 -19
  128. package/src/sandbox-host/SandboxHost.test.tsx +90 -0
  129. package/src/sandbox-host/SandboxHost.tsx +8 -2
  130. package/src/tests/RemoteThreadListRuntime.adapterProvider.test.tsx +24 -3
  131. package/src/tests/RemoteThreadListRuntime.reloadMainThread.test.tsx +1 -2
  132. package/src/tests/augmentations.test.ts +49 -0
  133. package/src/tests/in-memory-thread-list.test.tsx +1 -1
  134. package/src/tests/threadMessageById.test.tsx +61 -1
  135. package/src/unstable/useComposerInput.ts +2 -2
  136. package/src/unstable/useComposerInputHistory.test.tsx +4 -4
  137. package/src/unstable/useComposerInputHistory.ts +1 -1
  138. package/src/unstable/useLiveCompletionAdapter.test.tsx +157 -0
  139. package/src/unstable/useLiveCompletionAdapter.ts +41 -17
  140. package/src/utils/invokeCallbackSafely.ts +11 -0
  141. package/src/utils/smooth/useSmooth.test.tsx +110 -0
  142. package/src/utils/smooth/useSmooth.ts +20 -6
  143. package/dist/client/InMemoryThreadList.d.ts +0 -12
  144. package/dist/client/InMemoryThreadList.d.ts.map +0 -1
  145. package/dist/client/InMemoryThreadList.js +0 -374
  146. package/dist/client/InMemoryThreadList.js.map +0 -1
  147. package/dist/context/react/utils/ensureBinding.d.ts +0 -5
  148. package/dist/context/react/utils/ensureBinding.d.ts.map +0 -1
  149. package/dist/context/react/utils/ensureBinding.js +0 -24
  150. package/dist/context/react/utils/ensureBinding.js.map +0 -1
  151. package/dist/context/react/utils/useRuntimeState.d.ts +0 -13
  152. package/dist/context/react/utils/useRuntimeState.d.ts.map +0 -1
  153. package/dist/context/react/utils/useRuntimeState.js +0 -33
  154. package/dist/context/react/utils/useRuntimeState.js.map +0 -1
  155. package/dist/legacy-runtime/cloud/auiV0.d.ts +0 -119
  156. package/dist/legacy-runtime/cloud/auiV0.d.ts.map +0 -1
  157. package/dist/legacy-runtime/cloud/auiV0.js +0 -137
  158. package/dist/legacy-runtime/cloud/auiV0.js.map +0 -1
  159. package/dist/legacy-runtime/cloud/useCloudThreadListRuntime.d.ts +0 -16
  160. package/dist/legacy-runtime/cloud/useCloudThreadListRuntime.d.ts.map +0 -1
  161. package/dist/legacy-runtime/cloud/useCloudThreadListRuntime.js +0 -36
  162. package/dist/legacy-runtime/cloud/useCloudThreadListRuntime.js.map +0 -1
  163. package/dist/legacy-runtime/runtime-cores/assistant-transport/commandQueue.d.ts +0 -17
  164. package/dist/legacy-runtime/runtime-cores/assistant-transport/commandQueue.d.ts.map +0 -1
  165. package/dist/legacy-runtime/runtime-cores/assistant-transport/commandQueue.js +0 -55
  166. package/dist/legacy-runtime/runtime-cores/assistant-transport/commandQueue.js.map +0 -1
  167. package/dist/legacy-runtime/runtime-cores/assistant-transport/replayBoundaryStream.d.ts +0 -11
  168. package/dist/legacy-runtime/runtime-cores/assistant-transport/replayBoundaryStream.d.ts.map +0 -1
  169. package/dist/legacy-runtime/runtime-cores/assistant-transport/replayBoundaryStream.js +0 -165
  170. package/dist/legacy-runtime/runtime-cores/assistant-transport/replayBoundaryStream.js.map +0 -1
  171. package/dist/legacy-runtime/runtime-cores/assistant-transport/runManager.d.ts +0 -15
  172. package/dist/legacy-runtime/runtime-cores/assistant-transport/runManager.d.ts.map +0 -1
  173. package/dist/legacy-runtime/runtime-cores/assistant-transport/runManager.js +0 -81
  174. package/dist/legacy-runtime/runtime-cores/assistant-transport/runManager.js.map +0 -1
  175. package/dist/legacy-runtime/runtime-cores/assistant-transport/types.d.ts +0 -126
  176. package/dist/legacy-runtime/runtime-cores/assistant-transport/types.d.ts.map +0 -1
  177. package/dist/legacy-runtime/runtime-cores/assistant-transport/types.js +0 -0
  178. package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.d.ts +0 -14
  179. package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.d.ts.map +0 -1
  180. package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.js +0 -290
  181. package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.js.map +0 -1
  182. package/dist/legacy-runtime/runtime-cores/assistant-transport/useConvertedState.d.ts +0 -7
  183. package/dist/legacy-runtime/runtime-cores/assistant-transport/useConvertedState.d.ts.map +0 -1
  184. package/dist/legacy-runtime/runtime-cores/assistant-transport/useConvertedState.js +0 -25
  185. package/dist/legacy-runtime/runtime-cores/assistant-transport/useConvertedState.js.map +0 -1
  186. package/dist/legacy-runtime/runtime-cores/assistant-transport/useLatestRef.d.ts +0 -7
  187. package/dist/legacy-runtime/runtime-cores/assistant-transport/useLatestRef.d.ts.map +0 -1
  188. package/dist/legacy-runtime/runtime-cores/assistant-transport/useLatestRef.js +0 -27
  189. package/dist/legacy-runtime/runtime-cores/assistant-transport/useLatestRef.js.map +0 -1
  190. package/dist/legacy-runtime/runtime-cores/assistant-transport/utils.d.ts +0 -2
  191. package/dist/legacy-runtime/runtime-cores/assistant-transport/utils.js +0 -2
  192. package/src/client/InMemoryThreadList.ts +0 -247
  193. package/src/context/react/utils/ensureBinding.ts +0 -46
  194. package/src/context/react/utils/useRuntimeState.ts +0 -46
  195. package/src/legacy-runtime/cloud/auiV0.ts +0 -331
  196. package/src/legacy-runtime/cloud/useCloudThreadListRuntime.ts +0 -31
  197. package/src/legacy-runtime/runtime-cores/assistant-transport/commandQueue.ts +0 -68
  198. package/src/legacy-runtime/runtime-cores/assistant-transport/replayBoundaryStream.test.ts +0 -455
  199. package/src/legacy-runtime/runtime-cores/assistant-transport/replayBoundaryStream.ts +0 -172
  200. package/src/legacy-runtime/runtime-cores/assistant-transport/runManager.ts +0 -113
  201. package/src/legacy-runtime/runtime-cores/assistant-transport/transport-delivery.test.tsx +0 -79
  202. package/src/legacy-runtime/runtime-cores/assistant-transport/transport-parentid.test.tsx +0 -97
  203. package/src/legacy-runtime/runtime-cores/assistant-transport/transport-scheduling.test.ts +0 -307
  204. package/src/legacy-runtime/runtime-cores/assistant-transport/types.ts +0 -170
  205. package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransport.spec.md +0 -141
  206. package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.test.tsx +0 -585
  207. package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.ts +0 -476
  208. package/src/legacy-runtime/runtime-cores/assistant-transport/useConvertedState.ts +0 -20
  209. package/src/legacy-runtime/runtime-cores/assistant-transport/useLatestRef.ts +0 -9
  210. 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>;
@@ -22,6 +24,9 @@ export namespace ThreadPrimitiveRoot {
22
24
  * This component serves as the foundational wrapper for all thread-related components.
23
25
  * It provides the basic structure and context needed for thread functionality.
24
26
  *
27
+ * While this component is mounted, an unhandled Escape keydown stops active speech, even if
28
+ * the action bar that started it is no longer mounted.
29
+ *
25
30
  * @example
26
31
  * ```tsx
27
32
  * <ThreadPrimitive.Root>
@@ -37,6 +42,34 @@ export const ThreadPrimitiveRoot = forwardRef<
37
42
  ThreadPrimitiveRoot.Element,
38
43
  ThreadPrimitiveRoot.Props
39
44
  >((props, ref) => {
45
+ const aui = useAui();
46
+
47
+ useEffect(() => {
48
+ const handleKeyDown = (event: KeyboardEvent) => {
49
+ if (event.key !== "Escape") return;
50
+ if (event.defaultPrevented || aui.thread.source === null) return;
51
+ if (aui.thread.getState().speech == null) return;
52
+ event.preventDefault();
53
+ try {
54
+ aui.thread.stopSpeaking();
55
+ } catch (error) {
56
+ // getState() is the last rendered snapshot, so speech can finish before this call.
57
+ if (
58
+ !(error instanceof Error) ||
59
+ error.message !== "No message is being spoken"
60
+ ) {
61
+ throw error;
62
+ }
63
+ }
64
+ };
65
+
66
+ // Bubble phase lets capture-phase Escape handlers consume the event first.
67
+ document.addEventListener("keydown", handleKeyDown);
68
+ return () => {
69
+ document.removeEventListener("keydown", handleKeyDown);
70
+ };
71
+ }, [aui]);
72
+
40
73
  return <Primitive.div {...props} ref={ref} />;
41
74
  });
42
75
 
@@ -10,7 +10,7 @@ import {
10
10
  useLayoutEffect,
11
11
  useMemo,
12
12
  } from "react";
13
- import { useAuiEvent, useAuiState } from "@assistant-ui/store";
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
- if (scheduledFrameRef.current !== null) {
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
- } else if (
135
- lastScrollTop.current > div.scrollTop &&
136
- lastScrollHeight.current === div.scrollHeight
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
- threadViewportStore.getState().isAtBottom
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("threadListItem.switchedTo", () => {
234
+ useAuiEvent("threads.selectionChanged", () => {
228
235
  if (!scrollToBottomOnThreadSwitch) return;
229
236
  scheduleScrollToBottom("instant");
230
237
  });