@assistant-ui/react 0.15.14 → 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.
Files changed (160) 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/hooks/useMessageQuote.js +1 -1
  7. package/dist/hooks/useToolCallElapsed.js +1 -1
  8. package/dist/index.d.ts +4 -7
  9. package/dist/index.js +6 -8
  10. package/dist/legacy-runtime/AssistantRuntimeProvider.js +1 -1
  11. package/dist/mcp-apps/McpAppsRemoteHost.d.ts.map +1 -1
  12. package/dist/mcp-apps/McpAppsRemoteHost.js +18 -2
  13. package/dist/mcp-apps/McpAppsRemoteHost.js.map +1 -1
  14. package/dist/mcp-apps/bridge.js +39 -24
  15. package/dist/mcp-apps/bridge.js.map +1 -1
  16. package/dist/model-context/makeAssistantVisible.js +1 -1
  17. package/dist/primitives/actionBar/ActionBarCopy.js +1 -1
  18. package/dist/primitives/actionBar/ActionBarExportMarkdown.js +1 -1
  19. package/dist/primitives/actionBar/ActionBarFeedbackNegative.js +1 -1
  20. package/dist/primitives/actionBar/ActionBarFeedbackPositive.js +1 -1
  21. package/dist/primitives/actionBar/ActionBarStopSpeaking.d.ts.map +1 -1
  22. package/dist/primitives/actionBar/ActionBarStopSpeaking.js +24 -37
  23. package/dist/primitives/actionBar/ActionBarStopSpeaking.js.map +1 -1
  24. package/dist/primitives/actionBar/useActionBarFloatStatus.js +1 -1
  25. package/dist/primitives/assistantModal/AssistantModalRoot.js +1 -1
  26. package/dist/primitives/attachment/AttachmentName.js +1 -1
  27. package/dist/primitives/attachment/AttachmentRemove.js +1 -1
  28. package/dist/primitives/attachment/AttachmentThumb.js +1 -1
  29. package/dist/primitives/branchPicker/BranchPickerCount.js +1 -1
  30. package/dist/primitives/branchPicker/BranchPickerNumber.js +1 -1
  31. package/dist/primitives/chainOfThought/ChainOfThoughtAccordionTrigger.js +1 -1
  32. package/dist/primitives/composer/ComposerAddAttachment.js +1 -1
  33. package/dist/primitives/composer/ComposerAttachmentDropzone.js +1 -1
  34. package/dist/primitives/composer/ComposerDictationTranscript.js +1 -1
  35. package/dist/primitives/composer/ComposerInput.js +2 -2
  36. package/dist/primitives/composer/ComposerInput.js.map +1 -1
  37. package/dist/primitives/composer/ComposerQuote.js +1 -1
  38. package/dist/primitives/composer/ComposerRoot.js +1 -1
  39. package/dist/primitives/composer/ComposerStopDictation.js +1 -1
  40. package/dist/primitives/composer/trigger/TriggerPopover.js +1 -1
  41. package/dist/primitives/composer/useComposerInputState.js +1 -1
  42. package/dist/primitives/message/MessageIf.js +1 -1
  43. package/dist/primitives/message/MessagePartsGrouped.js +1 -1
  44. package/dist/primitives/message/MessageRoot.js +1 -1
  45. package/dist/primitives/queueItem/QueueItemRemove.js +1 -1
  46. package/dist/primitives/queueItem/QueueItemSteer.js +1 -1
  47. package/dist/primitives/queueItem/QueueItemText.js +1 -1
  48. package/dist/primitives/selectionToolbar/SelectionToolbarQuote.js +1 -1
  49. package/dist/primitives/suggestion/SuggestionDescription.js +1 -1
  50. package/dist/primitives/suggestion/SuggestionTitle.js +1 -1
  51. package/dist/primitives/suggestion/SuggestionTrigger.js +1 -1
  52. package/dist/primitives/thread/ThreadIf.js +1 -1
  53. package/dist/primitives/thread/ThreadRoot.d.ts.map +1 -1
  54. package/dist/primitives/thread/ThreadRoot.js +41 -9
  55. package/dist/primitives/thread/ThreadRoot.js.map +1 -1
  56. package/dist/primitives/thread/ThreadViewport.d.ts.map +1 -1
  57. package/dist/primitives/thread/ThreadViewport.js +2 -13
  58. package/dist/primitives/thread/ThreadViewport.js.map +1 -1
  59. package/dist/primitives/thread/useThreadViewportAutoScroll.d.ts.map +1 -1
  60. package/dist/primitives/thread/useThreadViewportAutoScroll.js +23 -8
  61. package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
  62. package/dist/primitives/threadList/ThreadListNew.js +1 -1
  63. package/dist/sandbox-host/SandboxHost.js +2 -0
  64. package/dist/sandbox-host/SandboxHost.js.map +1 -1
  65. package/dist/unstable/useComposerInput.d.ts +2 -2
  66. package/dist/unstable/useComposerInput.js +1 -1
  67. package/dist/unstable/useComposerInput.js.map +1 -1
  68. package/dist/unstable/useComposerInputHistory.js +1 -1
  69. package/dist/unstable/useComposerInputHistory.js.map +1 -1
  70. package/dist/unstable/useLiveCompletionAdapter.d.ts +5 -0
  71. package/dist/unstable/useLiveCompletionAdapter.d.ts.map +1 -1
  72. package/dist/unstable/useLiveCompletionAdapter.js +16 -3
  73. package/dist/unstable/useLiveCompletionAdapter.js.map +1 -1
  74. package/dist/unstable/useMentionAdapter.d.ts +1 -1
  75. package/dist/utils/smooth/SmoothContext.js +1 -1
  76. package/dist/utils/useToolArgsFieldStatus.js +1 -1
  77. package/package.json +9 -9
  78. package/src/assistant-transport.ts +94 -0
  79. package/src/index.ts +8 -4
  80. package/src/mcp-apps/McpAppsRemoteHost.test.ts +29 -3
  81. package/src/mcp-apps/McpAppsRemoteHost.ts +42 -7
  82. package/src/mcp-apps/bridge.test.ts +132 -0
  83. package/src/mcp-apps/bridge.ts +54 -30
  84. package/src/primitives/actionBar/ActionBarStopSpeaking.tsx +0 -9
  85. package/src/primitives/composer/ComposerInput.tsx +1 -1
  86. package/src/primitives/thread/ThreadRoot.test.tsx +251 -0
  87. package/src/primitives/thread/ThreadRoot.tsx +30 -0
  88. package/src/primitives/thread/ThreadViewport.tsx +1 -8
  89. package/src/primitives/thread/useThreadViewportAutoScroll.test.tsx +152 -0
  90. package/src/primitives/thread/useThreadViewportAutoScroll.ts +26 -19
  91. package/src/sandbox-host/SandboxHost.test.tsx +29 -0
  92. package/src/sandbox-host/SandboxHost.tsx +2 -0
  93. package/src/tests/RemoteThreadListRuntime.adapterProvider.test.tsx +24 -3
  94. package/src/tests/RemoteThreadListRuntime.reloadMainThread.test.tsx +1 -2
  95. package/src/tests/augmentations.test.ts +49 -0
  96. package/src/tests/in-memory-thread-list.test.tsx +1 -1
  97. package/src/tests/threadMessageById.test.tsx +61 -1
  98. package/src/unstable/useComposerInput.ts +2 -2
  99. package/src/unstable/useComposerInputHistory.test.tsx +4 -4
  100. package/src/unstable/useComposerInputHistory.ts +1 -1
  101. package/src/unstable/useLiveCompletionAdapter.test.tsx +100 -0
  102. package/src/unstable/useLiveCompletionAdapter.ts +41 -17
  103. package/dist/client/InMemoryThreadList.d.ts +0 -12
  104. package/dist/client/InMemoryThreadList.d.ts.map +0 -1
  105. package/dist/client/InMemoryThreadList.js +0 -374
  106. package/dist/client/InMemoryThreadList.js.map +0 -1
  107. package/dist/legacy-runtime/cloud/auiV0.d.ts +0 -119
  108. package/dist/legacy-runtime/cloud/auiV0.d.ts.map +0 -1
  109. package/dist/legacy-runtime/cloud/auiV0.js +0 -137
  110. package/dist/legacy-runtime/cloud/auiV0.js.map +0 -1
  111. package/dist/legacy-runtime/cloud/useCloudThreadListRuntime.d.ts +0 -16
  112. package/dist/legacy-runtime/cloud/useCloudThreadListRuntime.d.ts.map +0 -1
  113. package/dist/legacy-runtime/cloud/useCloudThreadListRuntime.js +0 -36
  114. package/dist/legacy-runtime/cloud/useCloudThreadListRuntime.js.map +0 -1
  115. package/dist/legacy-runtime/runtime-cores/assistant-transport/commandQueue.d.ts +0 -17
  116. package/dist/legacy-runtime/runtime-cores/assistant-transport/commandQueue.d.ts.map +0 -1
  117. package/dist/legacy-runtime/runtime-cores/assistant-transport/commandQueue.js +0 -55
  118. package/dist/legacy-runtime/runtime-cores/assistant-transport/commandQueue.js.map +0 -1
  119. package/dist/legacy-runtime/runtime-cores/assistant-transport/replayBoundaryStream.d.ts +0 -11
  120. package/dist/legacy-runtime/runtime-cores/assistant-transport/replayBoundaryStream.d.ts.map +0 -1
  121. package/dist/legacy-runtime/runtime-cores/assistant-transport/replayBoundaryStream.js +0 -165
  122. package/dist/legacy-runtime/runtime-cores/assistant-transport/replayBoundaryStream.js.map +0 -1
  123. package/dist/legacy-runtime/runtime-cores/assistant-transport/runManager.d.ts +0 -15
  124. package/dist/legacy-runtime/runtime-cores/assistant-transport/runManager.d.ts.map +0 -1
  125. package/dist/legacy-runtime/runtime-cores/assistant-transport/runManager.js +0 -81
  126. package/dist/legacy-runtime/runtime-cores/assistant-transport/runManager.js.map +0 -1
  127. package/dist/legacy-runtime/runtime-cores/assistant-transport/types.d.ts +0 -126
  128. package/dist/legacy-runtime/runtime-cores/assistant-transport/types.d.ts.map +0 -1
  129. package/dist/legacy-runtime/runtime-cores/assistant-transport/types.js +0 -0
  130. package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.d.ts +0 -14
  131. package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.d.ts.map +0 -1
  132. package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.js +0 -290
  133. package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.js.map +0 -1
  134. package/dist/legacy-runtime/runtime-cores/assistant-transport/useConvertedState.d.ts +0 -7
  135. package/dist/legacy-runtime/runtime-cores/assistant-transport/useConvertedState.d.ts.map +0 -1
  136. package/dist/legacy-runtime/runtime-cores/assistant-transport/useConvertedState.js +0 -25
  137. package/dist/legacy-runtime/runtime-cores/assistant-transport/useConvertedState.js.map +0 -1
  138. package/dist/legacy-runtime/runtime-cores/assistant-transport/useLatestRef.d.ts +0 -7
  139. package/dist/legacy-runtime/runtime-cores/assistant-transport/useLatestRef.d.ts.map +0 -1
  140. package/dist/legacy-runtime/runtime-cores/assistant-transport/useLatestRef.js +0 -27
  141. package/dist/legacy-runtime/runtime-cores/assistant-transport/useLatestRef.js.map +0 -1
  142. package/dist/legacy-runtime/runtime-cores/assistant-transport/utils.d.ts +0 -2
  143. package/dist/legacy-runtime/runtime-cores/assistant-transport/utils.js +0 -2
  144. package/src/client/InMemoryThreadList.ts +0 -247
  145. package/src/legacy-runtime/cloud/auiV0.ts +0 -331
  146. package/src/legacy-runtime/cloud/useCloudThreadListRuntime.ts +0 -31
  147. package/src/legacy-runtime/runtime-cores/assistant-transport/commandQueue.ts +0 -68
  148. package/src/legacy-runtime/runtime-cores/assistant-transport/replayBoundaryStream.test.ts +0 -455
  149. package/src/legacy-runtime/runtime-cores/assistant-transport/replayBoundaryStream.ts +0 -172
  150. package/src/legacy-runtime/runtime-cores/assistant-transport/runManager.ts +0 -113
  151. package/src/legacy-runtime/runtime-cores/assistant-transport/transport-delivery.test.tsx +0 -79
  152. package/src/legacy-runtime/runtime-cores/assistant-transport/transport-parentid.test.tsx +0 -97
  153. package/src/legacy-runtime/runtime-cores/assistant-transport/transport-scheduling.test.ts +0 -307
  154. package/src/legacy-runtime/runtime-cores/assistant-transport/types.ts +0 -170
  155. package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransport.spec.md +0 -141
  156. package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.test.tsx +0 -585
  157. package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.ts +0 -476
  158. package/src/legacy-runtime/runtime-cores/assistant-transport/useConvertedState.ts +0 -20
  159. package/src/legacy-runtime/runtime-cores/assistant-transport/useLatestRef.ts +0 -9
  160. 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 { 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
  });
@@ -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
  });
@@ -147,6 +147,8 @@ export function SandboxHost({
147
147
  window.addEventListener("message", onMessage);
148
148
  })
149
149
  .catch((err) => {
150
+ frame?.dispose();
151
+ frame = null;
150
152
  liveRef.current.onError?.(
151
153
  err instanceof Error ? err : new Error(String(err)),
152
154
  );