@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.
- package/LICENSE +21 -0
- 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/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/dist/utils/smooth/useSmooth.d.ts.map +1 -1
- package/dist/utils/smooth/useSmooth.js +27 -10
- package/dist/utils/smooth/useSmooth.js.map +1 -1
- package/package.json +14 -16
- 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/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
- 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
|
|
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
|
});
|
|
@@ -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
|
-
|
|
119
|
-
|
|
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
|
-
}, [
|
|
156
|
+
}, [animatorRef, smooth, text, state.status.type, part]);
|
|
141
157
|
|
|
142
158
|
useEffect(() => {
|
|
143
159
|
return () => {
|