@assistant-ui/react 0.14.5 → 0.14.7

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 (98) 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/dist/utils/smooth/useSmooth.d.ts.map +1 -1
  71. package/dist/utils/smooth/useSmooth.js +27 -10
  72. package/dist/utils/smooth/useSmooth.js.map +1 -1
  73. package/package.json +14 -16
  74. package/src/index.ts +6 -0
  75. package/src/legacy-runtime/AssistantRuntimeProvider.tsx +8 -2
  76. package/src/legacy-runtime/hooks/AssistantContext.ts +2 -2
  77. package/src/legacy-runtime/hooks/AttachmentContext.ts +2 -2
  78. package/src/legacy-runtime/hooks/ComposerContext.ts +2 -2
  79. package/src/legacy-runtime/hooks/MessageContext.ts +3 -3
  80. package/src/legacy-runtime/hooks/MessagePartContext.ts +2 -2
  81. package/src/legacy-runtime/hooks/ThreadContext.ts +4 -4
  82. package/src/legacy-runtime/hooks/ThreadListItemContext.ts +2 -2
  83. package/src/legacy-runtime/runtime-cores/assistant-transport/useToolInvocations.test.ts +254 -0
  84. package/src/mcp-apps/McpAppRenderer.tsx +8 -0
  85. package/src/mcp-apps/McpAppsRemoteHost.ts +7 -0
  86. package/src/mcp-apps/utils.ts +7 -0
  87. package/src/primitives/actionBar/ActionBarCopy.tsx +6 -1
  88. package/src/primitives/messagePart/MessagePartInProgress.ts +1 -17
  89. package/src/primitives/messagePart/useMessagePartData.ts +16 -0
  90. package/src/primitives/messagePart/useMessagePartFile.ts +15 -0
  91. package/src/primitives/messagePart/useMessagePartImage.ts +15 -0
  92. package/src/primitives/messagePart/useMessagePartReasoning.ts +15 -0
  93. package/src/primitives/messagePart/useMessagePartSource.ts +15 -0
  94. package/src/primitives/messagePart/useMessagePartText.ts +15 -0
  95. package/src/primitives/thread/useThreadViewportAutoScroll.test.tsx +320 -0
  96. package/src/primitives/thread/useThreadViewportAutoScroll.ts +59 -29
  97. package/src/tests/BaseComposerRuntimeCore.test.ts +1 -1
  98. package/src/utils/smooth/useSmooth.ts +28 -12
@@ -6,6 +6,21 @@ import type {
6
6
  } from "@assistant-ui/core";
7
7
  import { useAuiState } from "@assistant-ui/store";
8
8
 
9
+ /**
10
+ * @deprecated Use {@link useAuiState} to select and narrow `s.part`.
11
+ * Return `null` for optional rendering, or throw inside the selector to
12
+ * preserve the old hook's strict behavior.
13
+ *
14
+ * @example
15
+ * ```tsx
16
+ * const reasoning = useAuiState((s) => {
17
+ * if (s.part.type !== "reasoning") return null;
18
+ * return s.part;
19
+ * });
20
+ * ```
21
+ *
22
+ * See the {@link https://assistant-ui.com/docs/migrations/v0-12 migration guide}.
23
+ */
9
24
  export const useMessagePartReasoning = () => {
10
25
  const text = useAuiState((s) => {
11
26
  if (s.part.type !== "reasoning")
@@ -3,6 +3,21 @@
3
3
  import type { SourceMessagePart, MessagePartState } from "@assistant-ui/core";
4
4
  import { useAuiState } from "@assistant-ui/store";
5
5
 
6
+ /**
7
+ * @deprecated Use {@link useAuiState} to select and narrow `s.part`.
8
+ * Return `null` for optional rendering, or throw inside the selector to
9
+ * preserve the old hook's strict behavior.
10
+ *
11
+ * @example
12
+ * ```tsx
13
+ * const source = useAuiState((s) => {
14
+ * if (s.part.type !== "source") return null;
15
+ * return s.part;
16
+ * });
17
+ * ```
18
+ *
19
+ * See the {@link https://assistant-ui.com/docs/migrations/v0-12 migration guide}.
20
+ */
6
21
  export const useMessagePartSource = () => {
7
22
  const source = useAuiState((s) => {
8
23
  if (s.part.type !== "source")
@@ -7,6 +7,21 @@ import type {
7
7
  } from "@assistant-ui/core";
8
8
  import { useAuiState } from "@assistant-ui/store";
9
9
 
10
+ /**
11
+ * @deprecated Use {@link useAuiState} to select and narrow `s.part`.
12
+ * Return `null` for optional rendering, or throw inside the selector to
13
+ * preserve the old hook's strict behavior.
14
+ *
15
+ * @example
16
+ * ```tsx
17
+ * const text = useAuiState((s) => {
18
+ * if (s.part.type !== "text" && s.part.type !== "reasoning") return null;
19
+ * return s.part;
20
+ * });
21
+ * ```
22
+ *
23
+ * See the {@link https://assistant-ui.com/docs/migrations/v0-12 migration guide}.
24
+ */
10
25
  export const useMessagePartText = () => {
11
26
  const text = useAuiState((s) => {
12
27
  if (s.part.type !== "text" && s.part.type !== "reasoning")
@@ -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
  });
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { useEffect, useMemo, useRef, useState } from "react";
4
- import { useAuiState } from "@assistant-ui/store";
4
+ import { useAui, useAuiState } from "@assistant-ui/store";
5
5
  import type {
6
6
  MessagePartStatus,
7
7
  ReasoningMessagePart,
@@ -75,13 +75,28 @@ export const useSmooth = (
75
75
  smooth: boolean = false,
76
76
  ): MessagePartState & (TextMessagePart | ReasoningMessagePart) => {
77
77
  const { text } = state;
78
- const id = useAuiState((s) => s.message.id);
79
78
 
80
- const idRef = useRef(id);
81
79
  const [displayedText, setDisplayedText] = useState(
82
80
  state.status.type === "running" ? "" : text,
83
81
  );
84
82
 
83
+ // Render-phase resync on part flip or text discontinuity, so the
84
+ // first paint after a thread switch never shows the previous
85
+ // part's text (#4051). `displayedText` is already a prefix of
86
+ // `text` during normal streaming, so use it as the previous-text
87
+ // reference instead of carrying separate state — avoids the
88
+ // double render per streaming token. Read part identity through
89
+ // `useAuiState` so we actually subscribe to its changes instead
90
+ // of relying on a render-time proxy reference that may be stable
91
+ // across thread swaps.
92
+ const aui = useAui();
93
+ const part = useAuiState(() => aui.part());
94
+ const [prevPart, setPrevPart] = useState(part);
95
+ if (part !== prevPart || !text.startsWith(displayedText)) {
96
+ setPrevPart(part);
97
+ setDisplayedText(state.status.type === "running" ? "" : text);
98
+ }
99
+
85
100
  const smoothStatusStore = useSmoothStatusStore({ optional: true });
86
101
  const setText = useCallbackRef((text: string) => {
87
102
  setDisplayedText(text);
@@ -109,35 +124,36 @@ export const useSmooth = (
109
124
  new TextStreamAnimator(displayedText, setText),
110
125
  );
111
126
 
127
+ const animatorPartRef = useRef(part);
112
128
  useEffect(() => {
113
129
  if (!smooth) {
114
130
  animatorRef.stop();
115
131
  return;
116
132
  }
117
133
 
118
- if (idRef.current !== id || !text.startsWith(animatorRef.targetText)) {
119
- idRef.current = id;
120
-
134
+ // Discontinuity: part flipped, or new text breaks continuation
135
+ // of the animator's current target. Either case requires
136
+ // resetting the cursor — without the part check, a new part
137
+ // whose text happens to share a prefix with the previous target
138
+ // would keep the stale cursor and flicker.
139
+ const partChanged = animatorPartRef.current !== part;
140
+ animatorPartRef.current = part;
141
+ if (partChanged || !text.startsWith(animatorRef.targetText)) {
121
142
  if (state.status.type === "running") {
122
- // New streaming message → animate from empty string
123
- setText("");
124
143
  animatorRef.currentText = "";
125
144
  animatorRef.targetText = text;
126
145
  animatorRef.start();
127
146
  } else {
128
- // Completed message → display immediately
129
- setText(text);
130
147
  animatorRef.currentText = text;
131
148
  animatorRef.targetText = text;
132
149
  animatorRef.stop();
133
150
  }
134
-
135
151
  return;
136
152
  }
137
153
 
138
154
  animatorRef.targetText = text;
139
155
  animatorRef.start();
140
- }, [setText, animatorRef, id, smooth, text, state.status.type]);
156
+ }, [animatorRef, smooth, text, state.status.type, part]);
141
157
 
142
158
  useEffect(() => {
143
159
  return () => {