@assistant-ui/react 0.14.5 → 0.14.6

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 (94) hide show
  1. package/LICENSE +21 -0
  2. package/dist/index.d.ts +2 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +1 -0
  5. package/dist/index.js.map +1 -1
  6. package/dist/legacy-runtime/AssistantRuntimeProvider.d.ts +8 -2
  7. package/dist/legacy-runtime/AssistantRuntimeProvider.d.ts.map +1 -1
  8. package/dist/legacy-runtime/AssistantRuntimeProvider.js.map +1 -1
  9. package/dist/legacy-runtime/hooks/AssistantContext.d.ts +2 -2
  10. package/dist/legacy-runtime/hooks/AssistantContext.js +1 -1
  11. package/dist/legacy-runtime/hooks/AttachmentContext.d.ts +2 -2
  12. package/dist/legacy-runtime/hooks/AttachmentContext.js +1 -1
  13. package/dist/legacy-runtime/hooks/ComposerContext.d.ts +2 -2
  14. package/dist/legacy-runtime/hooks/ComposerContext.js +1 -1
  15. package/dist/legacy-runtime/hooks/MessageContext.d.ts +3 -3
  16. package/dist/legacy-runtime/hooks/MessageContext.js +2 -2
  17. package/dist/legacy-runtime/hooks/MessagePartContext.d.ts +2 -2
  18. package/dist/legacy-runtime/hooks/MessagePartContext.js +1 -1
  19. package/dist/legacy-runtime/hooks/ThreadContext.d.ts +4 -4
  20. package/dist/legacy-runtime/hooks/ThreadContext.js +2 -2
  21. package/dist/legacy-runtime/hooks/ThreadListItemContext.d.ts +2 -2
  22. package/dist/legacy-runtime/hooks/ThreadListItemContext.js +1 -1
  23. package/dist/mcp-apps/McpAppRenderer.d.ts +8 -0
  24. package/dist/mcp-apps/McpAppRenderer.d.ts.map +1 -1
  25. package/dist/mcp-apps/McpAppRenderer.js +8 -0
  26. package/dist/mcp-apps/McpAppRenderer.js.map +1 -1
  27. package/dist/mcp-apps/McpAppsRemoteHost.d.ts +7 -0
  28. package/dist/mcp-apps/McpAppsRemoteHost.d.ts.map +1 -1
  29. package/dist/mcp-apps/McpAppsRemoteHost.js +7 -0
  30. package/dist/mcp-apps/McpAppsRemoteHost.js.map +1 -1
  31. package/dist/mcp-apps/utils.d.ts +7 -0
  32. package/dist/mcp-apps/utils.d.ts.map +1 -1
  33. package/dist/mcp-apps/utils.js +7 -0
  34. package/dist/mcp-apps/utils.js.map +1 -1
  35. package/dist/primitives/actionBar/ActionBarCopy.d.ts.map +1 -1
  36. package/dist/primitives/actionBar/ActionBarCopy.js +6 -1
  37. package/dist/primitives/actionBar/ActionBarCopy.js.map +1 -1
  38. package/dist/primitives/messagePart/MessagePartInProgress.d.ts +1 -5
  39. package/dist/primitives/messagePart/MessagePartInProgress.d.ts.map +1 -1
  40. package/dist/primitives/messagePart/MessagePartInProgress.js +1 -7
  41. package/dist/primitives/messagePart/MessagePartInProgress.js.map +1 -1
  42. package/dist/primitives/messagePart/useMessagePartData.d.ts +16 -0
  43. package/dist/primitives/messagePart/useMessagePartData.d.ts.map +1 -1
  44. package/dist/primitives/messagePart/useMessagePartData.js +16 -0
  45. package/dist/primitives/messagePart/useMessagePartData.js.map +1 -1
  46. package/dist/primitives/messagePart/useMessagePartFile.d.ts +15 -0
  47. package/dist/primitives/messagePart/useMessagePartFile.d.ts.map +1 -1
  48. package/dist/primitives/messagePart/useMessagePartFile.js +15 -0
  49. package/dist/primitives/messagePart/useMessagePartFile.js.map +1 -1
  50. package/dist/primitives/messagePart/useMessagePartImage.d.ts +15 -0
  51. package/dist/primitives/messagePart/useMessagePartImage.d.ts.map +1 -1
  52. package/dist/primitives/messagePart/useMessagePartImage.js +15 -0
  53. package/dist/primitives/messagePart/useMessagePartImage.js.map +1 -1
  54. package/dist/primitives/messagePart/useMessagePartReasoning.d.ts +15 -0
  55. package/dist/primitives/messagePart/useMessagePartReasoning.d.ts.map +1 -1
  56. package/dist/primitives/messagePart/useMessagePartReasoning.js +15 -0
  57. package/dist/primitives/messagePart/useMessagePartReasoning.js.map +1 -1
  58. package/dist/primitives/messagePart/useMessagePartSource.d.ts +15 -0
  59. package/dist/primitives/messagePart/useMessagePartSource.d.ts.map +1 -1
  60. package/dist/primitives/messagePart/useMessagePartSource.js +15 -0
  61. package/dist/primitives/messagePart/useMessagePartSource.js.map +1 -1
  62. package/dist/primitives/messagePart/useMessagePartText.d.ts +15 -0
  63. package/dist/primitives/messagePart/useMessagePartText.d.ts.map +1 -1
  64. package/dist/primitives/messagePart/useMessagePartText.js +15 -0
  65. package/dist/primitives/messagePart/useMessagePartText.js.map +1 -1
  66. package/dist/primitives/thread/useThreadViewportAutoScroll.d.ts +1 -1
  67. package/dist/primitives/thread/useThreadViewportAutoScroll.d.ts.map +1 -1
  68. package/dist/primitives/thread/useThreadViewportAutoScroll.js +50 -27
  69. package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
  70. package/package.json +14 -16
  71. package/src/index.ts +6 -0
  72. package/src/legacy-runtime/AssistantRuntimeProvider.tsx +8 -2
  73. package/src/legacy-runtime/hooks/AssistantContext.ts +2 -2
  74. package/src/legacy-runtime/hooks/AttachmentContext.ts +2 -2
  75. package/src/legacy-runtime/hooks/ComposerContext.ts +2 -2
  76. package/src/legacy-runtime/hooks/MessageContext.ts +3 -3
  77. package/src/legacy-runtime/hooks/MessagePartContext.ts +2 -2
  78. package/src/legacy-runtime/hooks/ThreadContext.ts +4 -4
  79. package/src/legacy-runtime/hooks/ThreadListItemContext.ts +2 -2
  80. package/src/legacy-runtime/runtime-cores/assistant-transport/useToolInvocations.test.ts +254 -0
  81. package/src/mcp-apps/McpAppRenderer.tsx +8 -0
  82. package/src/mcp-apps/McpAppsRemoteHost.ts +7 -0
  83. package/src/mcp-apps/utils.ts +7 -0
  84. package/src/primitives/actionBar/ActionBarCopy.tsx +6 -1
  85. package/src/primitives/messagePart/MessagePartInProgress.ts +1 -17
  86. package/src/primitives/messagePart/useMessagePartData.ts +16 -0
  87. package/src/primitives/messagePart/useMessagePartFile.ts +15 -0
  88. package/src/primitives/messagePart/useMessagePartImage.ts +15 -0
  89. package/src/primitives/messagePart/useMessagePartReasoning.ts +15 -0
  90. package/src/primitives/messagePart/useMessagePartSource.ts +15 -0
  91. package/src/primitives/messagePart/useMessagePartText.ts +15 -0
  92. package/src/primitives/thread/useThreadViewportAutoScroll.test.tsx +320 -0
  93. package/src/primitives/thread/useThreadViewportAutoScroll.ts +59 -29
  94. package/src/tests/BaseComposerRuntimeCore.test.ts +1 -1
@@ -0,0 +1,320 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
4
+ import {
5
+ afterAll,
6
+ afterEach,
7
+ beforeAll,
8
+ describe,
9
+ expect,
10
+ it,
11
+ vi,
12
+ } from "vitest";
13
+ import { useEffect, useState, type FC, type PropsWithChildren } from "react";
14
+ import { AssistantRuntimeProvider } from "../../context";
15
+ import * as MessagePrimitive from "../message";
16
+ import { ThreadPrimitiveMessages } from "./ThreadMessages";
17
+ import { ThreadPrimitiveRoot } from "./ThreadRoot";
18
+ import { ThreadPrimitiveViewport } from "./ThreadViewport";
19
+ import {
20
+ ExportedMessageRepository,
21
+ useLocalRuntime,
22
+ type ChatModelAdapter,
23
+ type ThreadHistoryAdapter,
24
+ type ThreadMessageLike,
25
+ } from "../../index";
26
+
27
+ const adapter: ChatModelAdapter = {
28
+ async *run() {},
29
+ };
30
+
31
+ const messages: ThreadMessageLike[] = Array.from({ length: 8 }, (_, index) => ({
32
+ role: index % 2 === 0 ? "user" : "assistant",
33
+ content: [{ type: "text", text: `Message ${index + 1}` }],
34
+ }));
35
+
36
+ const getViewport = () => screen.getByTestId("viewport");
37
+
38
+ const getMaxScrollTop = (element: Element) =>
39
+ Math.max(0, element.scrollHeight - element.clientHeight);
40
+
41
+ let forceShortViewportMeasurement = false;
42
+ const resizeObserverCallbacks = new Set<ResizeObserverCallback>();
43
+
44
+ class TestResizeObserver {
45
+ private callback: ResizeObserverCallback;
46
+
47
+ constructor(callback: ResizeObserverCallback) {
48
+ this.callback = callback;
49
+ resizeObserverCallbacks.add(callback);
50
+ }
51
+
52
+ observe() {}
53
+ disconnect() {
54
+ resizeObserverCallbacks.delete(this.callback);
55
+ }
56
+ }
57
+
58
+ const notifyResizeObservers = () => {
59
+ for (const callback of resizeObserverCallbacks) {
60
+ callback([], {} as ResizeObserver);
61
+ }
62
+ };
63
+
64
+ const descriptors = {
65
+ scrollTop: Object.getOwnPropertyDescriptor(
66
+ HTMLElement.prototype,
67
+ "scrollTop",
68
+ ),
69
+ scrollHeight: Object.getOwnPropertyDescriptor(
70
+ HTMLElement.prototype,
71
+ "scrollHeight",
72
+ ),
73
+ clientHeight: Object.getOwnPropertyDescriptor(
74
+ HTMLElement.prototype,
75
+ "clientHeight",
76
+ ),
77
+ scrollTo: Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollTo"),
78
+ };
79
+
80
+ const scrollTopByElement = new WeakMap<Element, number>();
81
+
82
+ beforeAll(() => {
83
+ vi.stubGlobal("ResizeObserver", TestResizeObserver);
84
+ vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) =>
85
+ window.setTimeout(() => callback(performance.now()), 0),
86
+ );
87
+ vi.stubGlobal("cancelAnimationFrame", (id: number) =>
88
+ window.clearTimeout(id),
89
+ );
90
+
91
+ Object.defineProperty(HTMLElement.prototype, "scrollTop", {
92
+ configurable: true,
93
+ get() {
94
+ return scrollTopByElement.get(this) ?? 0;
95
+ },
96
+ set(value: number) {
97
+ scrollTopByElement.set(this, value);
98
+ },
99
+ });
100
+
101
+ Object.defineProperty(HTMLElement.prototype, "clientHeight", {
102
+ configurable: true,
103
+ get() {
104
+ return this.getAttribute("data-testid") === "viewport" ? 100 : 0;
105
+ },
106
+ });
107
+
108
+ Object.defineProperty(HTMLElement.prototype, "scrollHeight", {
109
+ configurable: true,
110
+ get() {
111
+ if (this.getAttribute("data-testid") !== "viewport") return 0;
112
+ if (forceShortViewportMeasurement) return this.clientHeight;
113
+ return (
114
+ document.querySelectorAll('[data-testid="thread-message"]').length * 80
115
+ );
116
+ },
117
+ });
118
+
119
+ Object.defineProperty(HTMLElement.prototype, "scrollTo", {
120
+ configurable: true,
121
+ value({ top = 0 }: ScrollToOptions) {
122
+ this.scrollTop = Math.min(Number(top), getMaxScrollTop(this));
123
+ this.dispatchEvent(new Event("scroll"));
124
+ },
125
+ });
126
+ });
127
+
128
+ afterEach(() => {
129
+ forceShortViewportMeasurement = false;
130
+ resizeObserverCallbacks.clear();
131
+ cleanup();
132
+ });
133
+
134
+ afterAll(() => {
135
+ vi.unstubAllGlobals();
136
+
137
+ for (const [key, descriptor] of Object.entries(descriptors)) {
138
+ if (descriptor) {
139
+ Object.defineProperty(HTMLElement.prototype, key, descriptor);
140
+ }
141
+ }
142
+ });
143
+
144
+ const Message: FC = () => (
145
+ <MessagePrimitive.Root data-testid="thread-message">
146
+ <MessagePrimitive.Content />
147
+ </MessagePrimitive.Root>
148
+ );
149
+
150
+ const Thread = ({
151
+ autoScroll,
152
+ scrollToBottomOnInitialize,
153
+ }: {
154
+ autoScroll?: boolean | undefined;
155
+ scrollToBottomOnInitialize?: boolean | undefined;
156
+ }) => (
157
+ <ThreadPrimitiveRoot>
158
+ <ThreadPrimitiveViewport
159
+ autoScroll={autoScroll}
160
+ data-testid="viewport"
161
+ turnAnchor="top"
162
+ scrollToBottomOnInitialize={scrollToBottomOnInitialize}
163
+ >
164
+ <ThreadPrimitiveMessages components={{ Message }} />
165
+ </ThreadPrimitiveViewport>
166
+ </ThreadPrimitiveRoot>
167
+ );
168
+
169
+ const BottomAnchorThread = () => (
170
+ <ThreadPrimitiveRoot>
171
+ <ThreadPrimitiveViewport data-testid="viewport">
172
+ <ThreadPrimitiveMessages components={{ Message }} />
173
+ </ThreadPrimitiveViewport>
174
+ </ThreadPrimitiveRoot>
175
+ );
176
+
177
+ const SyncRuntimeProvider: FC<PropsWithChildren> = ({ children }) => {
178
+ const runtime = useLocalRuntime(adapter, { initialMessages: messages });
179
+
180
+ return (
181
+ <AssistantRuntimeProvider runtime={runtime}>
182
+ {children}
183
+ </AssistantRuntimeProvider>
184
+ );
185
+ };
186
+
187
+ const AsyncRuntimeProvider: FC<PropsWithChildren> = ({ children }) => {
188
+ const history: ThreadHistoryAdapter = {
189
+ async load() {
190
+ await Promise.resolve();
191
+ return ExportedMessageRepository.fromArray(messages);
192
+ },
193
+ async append() {},
194
+ };
195
+ const runtime = useLocalRuntime(adapter, { adapters: { history } });
196
+
197
+ return (
198
+ <AssistantRuntimeProvider runtime={runtime}>
199
+ {children}
200
+ </AssistantRuntimeProvider>
201
+ );
202
+ };
203
+
204
+ const DelayedThread = ({
205
+ autoScroll,
206
+ scrollToBottomOnInitialize,
207
+ }: {
208
+ autoScroll?: boolean | undefined;
209
+ scrollToBottomOnInitialize?: boolean | undefined;
210
+ }) => {
211
+ const [showThread, setShowThread] = useState(false);
212
+
213
+ useEffect(() => {
214
+ setShowThread(true);
215
+ }, []);
216
+
217
+ if (!showThread) return null;
218
+ return (
219
+ <Thread
220
+ autoScroll={autoScroll}
221
+ scrollToBottomOnInitialize={scrollToBottomOnInitialize}
222
+ />
223
+ );
224
+ };
225
+
226
+ describe("useThreadViewportAutoScroll", () => {
227
+ it("scrolls sync initialMessages to the bottom when the viewport mounts after initialization", async () => {
228
+ render(
229
+ <SyncRuntimeProvider>
230
+ <DelayedThread />
231
+ </SyncRuntimeProvider>,
232
+ );
233
+
234
+ await waitFor(() => {
235
+ expect(screen.getAllByTestId("thread-message")).toHaveLength(
236
+ messages.length,
237
+ );
238
+ expect(getViewport().scrollTop).toBe(getMaxScrollTop(getViewport()));
239
+ });
240
+ });
241
+
242
+ it("keeps async history initialization scroll pending until imported messages are measurable", async () => {
243
+ forceShortViewportMeasurement = true;
244
+
245
+ render(
246
+ <AsyncRuntimeProvider>
247
+ <Thread />
248
+ </AsyncRuntimeProvider>,
249
+ );
250
+
251
+ await waitFor(() => {
252
+ expect(screen.getAllByTestId("thread-message")).toHaveLength(
253
+ messages.length,
254
+ );
255
+ });
256
+
257
+ await new Promise((resolve) => setTimeout(resolve, 0));
258
+ expect(getViewport().scrollTop).toBe(0);
259
+
260
+ forceShortViewportMeasurement = false;
261
+ notifyResizeObservers();
262
+
263
+ await waitFor(() => {
264
+ expect(getViewport().scrollTop).toBe(getMaxScrollTop(getViewport()));
265
+ });
266
+ });
267
+
268
+ it("preserves run-start's auto behavior on the first message of an empty thread", async () => {
269
+ const scrollToSpy = vi.spyOn(HTMLElement.prototype, "scrollTo");
270
+
271
+ let runtime: ReturnType<typeof useLocalRuntime> | null = null;
272
+ const Harness: FC = () => {
273
+ runtime = useLocalRuntime(adapter);
274
+ return (
275
+ <AssistantRuntimeProvider runtime={runtime}>
276
+ <BottomAnchorThread />
277
+ </AssistantRuntimeProvider>
278
+ );
279
+ };
280
+
281
+ render(<Harness />);
282
+
283
+ expect(screen.queryAllByTestId("thread-message")).toHaveLength(0);
284
+
285
+ await act(async () => {
286
+ runtime!.thread.append({
287
+ role: "user",
288
+ content: [{ type: "text", text: "hello" }],
289
+ });
290
+ });
291
+
292
+ await waitFor(() => {
293
+ expect(scrollToSpy).toHaveBeenCalled();
294
+ });
295
+
296
+ const behaviors = scrollToSpy.mock.calls.map(
297
+ (call) => (call[0] as ScrollToOptions).behavior,
298
+ );
299
+ expect(behaviors[0]).toBe("auto");
300
+ expect(behaviors).not.toContain("instant");
301
+
302
+ scrollToSpy.mockRestore();
303
+ });
304
+
305
+ it("does not scroll initial messages when initialize scrolling is disabled", async () => {
306
+ render(
307
+ <SyncRuntimeProvider>
308
+ <Thread autoScroll={false} scrollToBottomOnInitialize={false} />
309
+ </SyncRuntimeProvider>,
310
+ );
311
+
312
+ await waitFor(() => {
313
+ expect(screen.getAllByTestId("thread-message")).toHaveLength(
314
+ messages.length,
315
+ );
316
+ });
317
+
318
+ expect(getViewport().scrollTop).toBe(0);
319
+ });
320
+ });
@@ -1,8 +1,8 @@
1
1
  "use client";
2
2
 
3
3
  import { useComposedRefs } from "@radix-ui/react-compose-refs";
4
- import { useCallback, useRef, type RefCallback } from "react";
5
- import { useAuiEvent } from "@assistant-ui/store";
4
+ import { useCallback, useLayoutEffect, useRef, type RefCallback } from "react";
5
+ import { useAuiEvent, useAuiState } from "@assistant-ui/store";
6
6
  import { useOnResizeContent } from "../../utils/hooks/useOnResizeContent";
7
7
  import { useOnScrollToBottom } from "../../utils/hooks/useOnScrollToBottom";
8
8
  import { useManagedRef } from "../../utils/hooks/useManagedRef";
@@ -27,7 +27,7 @@ export namespace useThreadViewportAutoScroll {
27
27
  scrollToBottomOnRunStart?: boolean | undefined;
28
28
 
29
29
  /**
30
- * Whether to scroll to bottom when thread history is first loaded.
30
+ * Whether to scroll to bottom when messages first appear in the thread.
31
31
  *
32
32
  * Defaults to true.
33
33
  */
@@ -49,6 +49,9 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
49
49
  scrollToBottomOnThreadSwitch = true,
50
50
  }: useThreadViewportAutoScroll.Options): RefCallback<TElement> => {
51
51
  const divRef = useRef<TElement>(null);
52
+ const hasMessages = useAuiState((s) => s.thread.messages.length > 0);
53
+ const initializeScrollRequestedRef = useRef(false);
54
+ const scheduledFrameRef = useRef<number | null>(null);
52
55
 
53
56
  const threadViewportStore = useThreadViewportStore();
54
57
  if (autoScroll === undefined) {
@@ -57,9 +60,8 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
57
60
 
58
61
  const lastScrollTop = useRef<number>(0);
59
62
 
60
- // bug: when ScrollToBottom's button changes its disabled state, the scroll stops
61
- // fix: delay the state change until the scroll is done
62
- // stores the scroll behavior to reuse during content resize, or null if not scrolling
63
+ // Pending bottom-scroll intent. Planted by initialize/run-start/switch/button
64
+ // triggers, cleared only when handleScroll confirms we reached bottom.
63
65
  const scrollingToBottomBehaviorRef = useRef<ScrollBehavior | null>(null);
64
66
 
65
67
  const scrollToBottom = useCallback((behavior: ScrollBehavior) => {
@@ -70,6 +72,29 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
70
72
  div.scrollTo({ top: div.scrollHeight, behavior });
71
73
  }, []);
72
74
 
75
+ const scheduleScrollToBottom = useCallback(
76
+ (behavior: ScrollBehavior) => {
77
+ scrollingToBottomBehaviorRef.current = behavior;
78
+ if (scheduledFrameRef.current !== null) {
79
+ cancelAnimationFrame(scheduledFrameRef.current);
80
+ }
81
+ scheduledFrameRef.current = requestAnimationFrame(() => {
82
+ scheduledFrameRef.current = null;
83
+ scrollToBottom(behavior);
84
+ });
85
+ },
86
+ [scrollToBottom],
87
+ );
88
+
89
+ useLayoutEffect(
90
+ () => () => {
91
+ if (scheduledFrameRef.current !== null) {
92
+ cancelAnimationFrame(scheduledFrameRef.current);
93
+ }
94
+ },
95
+ [],
96
+ );
97
+
73
98
  const hasActiveTopAnchor = useCallback(() => {
74
99
  const state = threadViewportStore.getState();
75
100
  return (
@@ -88,11 +113,19 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
88
113
  Math.abs(div.scrollHeight - div.scrollTop - div.clientHeight) < 1 ||
89
114
  div.scrollHeight <= div.clientHeight;
90
115
 
91
- if (!newIsAtBottom && lastScrollTop.current < div.scrollTop) {
92
- // ignore scroll down
116
+ const isInFlightDownwardScroll =
117
+ !newIsAtBottom && lastScrollTop.current < div.scrollTop;
118
+ if (isInFlightDownwardScroll) {
119
+ // no-op: a smooth scroll-to-bottom fires many midpoint scroll events
120
+ // before landing, don't flicker isAtBottom or clear intent mid-animation
93
121
  } else {
94
122
  if (newIsAtBottom) {
95
- scrollingToBottomBehaviorRef.current = null;
123
+ // newIsAtBottom is ambiguous when the viewport doesn't overflow —
124
+ // keep intent alive until content can actually scroll
125
+ const viewportOverflows = div.scrollHeight > div.clientHeight + 1;
126
+ if (viewportOverflows) {
127
+ scrollingToBottomBehaviorRef.current = null;
128
+ }
96
129
  }
97
130
 
98
131
  const shouldUpdate =
@@ -129,37 +162,34 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
129
162
  };
130
163
  });
131
164
 
165
+ useLayoutEffect(() => {
166
+ if (!scrollToBottomOnInitialize) return;
167
+ if (!hasMessages) {
168
+ initializeScrollRequestedRef.current = false;
169
+ return;
170
+ }
171
+ if (initializeScrollRequestedRef.current) return;
172
+
173
+ initializeScrollRequestedRef.current = true;
174
+ // defer to an in-flight run (e.g. first message on a new thread) that
175
+ // already planted intent — otherwise we'd downgrade its "auto" to "instant"
176
+ if (scrollingToBottomBehaviorRef.current !== null) return;
177
+ scheduleScrollToBottom("instant");
178
+ }, [hasMessages, scheduleScrollToBottom, scrollToBottomOnInitialize]);
179
+
132
180
  useOnScrollToBottom(({ behavior }) => {
133
181
  scrollToBottom(behavior);
134
182
  });
135
183
 
136
- // autoscroll on run start
137
184
  useAuiEvent("thread.runStart", () => {
138
185
  if (!scrollToBottomOnRunStart) return;
139
186
  if (threadViewportStore.getState().turnAnchor === "top") return;
140
-
141
- scrollingToBottomBehaviorRef.current = "auto";
142
- requestAnimationFrame(() => {
143
- scrollToBottom("auto");
144
- });
145
- });
146
-
147
- // scroll to bottom instantly when thread history is first loaded
148
- useAuiEvent("thread.initialize", () => {
149
- if (!scrollToBottomOnInitialize) return;
150
- scrollingToBottomBehaviorRef.current = "instant";
151
- requestAnimationFrame(() => {
152
- scrollToBottom("instant");
153
- });
187
+ scheduleScrollToBottom("auto");
154
188
  });
155
189
 
156
- // scroll to bottom instantly when switching threads
157
190
  useAuiEvent("threadListItem.switchedTo", () => {
158
191
  if (!scrollToBottomOnThreadSwitch) return;
159
- scrollingToBottomBehaviorRef.current = "instant";
160
- requestAnimationFrame(() => {
161
- scrollToBottom("instant");
162
- });
192
+ scheduleScrollToBottom("instant");
163
193
  });
164
194
 
165
195
  const autoScrollRef = useComposedRefs<TElement>(resizeRef, scrollRef, divRef);
@@ -114,7 +114,7 @@ describe("BaseComposerRuntimeCore", () => {
114
114
  });
115
115
 
116
116
  it("sets and gets runConfig", () => {
117
- const config = { custom: { model: "gpt-4" } };
117
+ const config = { custom: { model: "gpt-5.4-nano" } };
118
118
  composer.setRunConfig(config);
119
119
  expect(composer.runConfig).toBe(config);
120
120
  });