@assistant-ui/react 0.14.4 → 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.
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/legacy-runtime/AssistantRuntimeProvider.d.ts +8 -2
- package/dist/legacy-runtime/AssistantRuntimeProvider.d.ts.map +1 -1
- package/dist/legacy-runtime/AssistantRuntimeProvider.js.map +1 -1
- package/dist/legacy-runtime/hooks/AssistantContext.d.ts +2 -2
- package/dist/legacy-runtime/hooks/AssistantContext.js +1 -1
- package/dist/legacy-runtime/hooks/AttachmentContext.d.ts +2 -2
- package/dist/legacy-runtime/hooks/AttachmentContext.js +1 -1
- package/dist/legacy-runtime/hooks/ComposerContext.d.ts +2 -2
- package/dist/legacy-runtime/hooks/ComposerContext.js +1 -1
- package/dist/legacy-runtime/hooks/MessageContext.d.ts +3 -3
- package/dist/legacy-runtime/hooks/MessageContext.js +2 -2
- package/dist/legacy-runtime/hooks/MessagePartContext.d.ts +2 -2
- package/dist/legacy-runtime/hooks/MessagePartContext.js +1 -1
- package/dist/legacy-runtime/hooks/ThreadContext.d.ts +4 -4
- package/dist/legacy-runtime/hooks/ThreadContext.js +2 -2
- package/dist/legacy-runtime/hooks/ThreadListItemContext.d.ts +2 -2
- package/dist/legacy-runtime/hooks/ThreadListItemContext.js +1 -1
- package/dist/mcp-apps/McpAppRenderer.d.ts +8 -0
- package/dist/mcp-apps/McpAppRenderer.d.ts.map +1 -1
- package/dist/mcp-apps/McpAppRenderer.js +8 -0
- package/dist/mcp-apps/McpAppRenderer.js.map +1 -1
- package/dist/mcp-apps/McpAppsRemoteHost.d.ts +7 -0
- package/dist/mcp-apps/McpAppsRemoteHost.d.ts.map +1 -1
- package/dist/mcp-apps/McpAppsRemoteHost.js +7 -0
- package/dist/mcp-apps/McpAppsRemoteHost.js.map +1 -1
- package/dist/mcp-apps/bridge.d.ts.map +1 -1
- package/dist/mcp-apps/bridge.js +15 -2
- package/dist/mcp-apps/bridge.js.map +1 -1
- package/dist/mcp-apps/utils.d.ts +7 -0
- package/dist/mcp-apps/utils.d.ts.map +1 -1
- package/dist/mcp-apps/utils.js +7 -0
- package/dist/mcp-apps/utils.js.map +1 -1
- package/dist/primitives/actionBar/ActionBarCopy.d.ts.map +1 -1
- package/dist/primitives/actionBar/ActionBarCopy.js +6 -1
- package/dist/primitives/actionBar/ActionBarCopy.js.map +1 -1
- package/dist/primitives/messagePart/MessagePartInProgress.d.ts +1 -5
- package/dist/primitives/messagePart/MessagePartInProgress.d.ts.map +1 -1
- package/dist/primitives/messagePart/MessagePartInProgress.js +1 -7
- package/dist/primitives/messagePart/MessagePartInProgress.js.map +1 -1
- package/dist/primitives/messagePart/useMessagePartData.d.ts +16 -0
- package/dist/primitives/messagePart/useMessagePartData.d.ts.map +1 -1
- package/dist/primitives/messagePart/useMessagePartData.js +16 -0
- package/dist/primitives/messagePart/useMessagePartData.js.map +1 -1
- package/dist/primitives/messagePart/useMessagePartFile.d.ts +15 -0
- package/dist/primitives/messagePart/useMessagePartFile.d.ts.map +1 -1
- package/dist/primitives/messagePart/useMessagePartFile.js +15 -0
- package/dist/primitives/messagePart/useMessagePartFile.js.map +1 -1
- package/dist/primitives/messagePart/useMessagePartImage.d.ts +15 -0
- package/dist/primitives/messagePart/useMessagePartImage.d.ts.map +1 -1
- package/dist/primitives/messagePart/useMessagePartImage.js +15 -0
- package/dist/primitives/messagePart/useMessagePartImage.js.map +1 -1
- package/dist/primitives/messagePart/useMessagePartReasoning.d.ts +15 -0
- package/dist/primitives/messagePart/useMessagePartReasoning.d.ts.map +1 -1
- package/dist/primitives/messagePart/useMessagePartReasoning.js +15 -0
- package/dist/primitives/messagePart/useMessagePartReasoning.js.map +1 -1
- package/dist/primitives/messagePart/useMessagePartSource.d.ts +15 -0
- package/dist/primitives/messagePart/useMessagePartSource.d.ts.map +1 -1
- package/dist/primitives/messagePart/useMessagePartSource.js +15 -0
- package/dist/primitives/messagePart/useMessagePartSource.js.map +1 -1
- package/dist/primitives/messagePart/useMessagePartText.d.ts +15 -0
- package/dist/primitives/messagePart/useMessagePartText.d.ts.map +1 -1
- package/dist/primitives/messagePart/useMessagePartText.js +15 -0
- package/dist/primitives/messagePart/useMessagePartText.js.map +1 -1
- package/dist/primitives/thread/useThreadViewportAutoScroll.d.ts +1 -1
- package/dist/primitives/thread/useThreadViewportAutoScroll.d.ts.map +1 -1
- package/dist/primitives/thread/useThreadViewportAutoScroll.js +50 -27
- package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
- package/package.json +7 -7
- package/src/index.ts +6 -0
- package/src/legacy-runtime/AssistantRuntimeProvider.tsx +8 -2
- package/src/legacy-runtime/hooks/AssistantContext.ts +2 -2
- package/src/legacy-runtime/hooks/AttachmentContext.ts +2 -2
- package/src/legacy-runtime/hooks/ComposerContext.ts +2 -2
- package/src/legacy-runtime/hooks/MessageContext.ts +3 -3
- package/src/legacy-runtime/hooks/MessagePartContext.ts +2 -2
- package/src/legacy-runtime/hooks/ThreadContext.ts +4 -4
- package/src/legacy-runtime/hooks/ThreadListItemContext.ts +2 -2
- package/src/legacy-runtime/runtime-cores/assistant-transport/useToolInvocations.test.ts +254 -0
- package/src/mcp-apps/McpAppRenderer.tsx +8 -0
- package/src/mcp-apps/McpAppsRemoteHost.ts +7 -0
- package/src/mcp-apps/bridge.ts +18 -2
- package/src/mcp-apps/utils.ts +7 -0
- package/src/primitives/actionBar/ActionBarCopy.tsx +6 -1
- package/src/primitives/messagePart/MessagePartInProgress.ts +1 -17
- package/src/primitives/messagePart/useMessagePartData.ts +16 -0
- package/src/primitives/messagePart/useMessagePartFile.ts +15 -0
- package/src/primitives/messagePart/useMessagePartImage.ts +15 -0
- package/src/primitives/messagePart/useMessagePartReasoning.ts +15 -0
- package/src/primitives/messagePart/useMessagePartSource.ts +15 -0
- package/src/primitives/messagePart/useMessagePartText.ts +15 -0
- package/src/primitives/thread/useThreadViewportAutoScroll.test.tsx +320 -0
- package/src/primitives/thread/useThreadViewportAutoScroll.ts +59 -29
- package/src/tests/BaseComposerRuntimeCore.test.ts +1 -1
|
@@ -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
|
|
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
|
-
//
|
|
61
|
-
//
|
|
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
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|