@assistant-ui/react 0.15.18 → 0.15.19
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/README.md +7 -1
- package/dist/context/providers/ThreadViewportProvider.js +2 -2
- package/dist/context/providers/ThreadViewportProvider.js.map +1 -1
- package/dist/hooks/useToolCallElapsed.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/mcp-apps/app-frame.d.ts.map +1 -1
- package/dist/mcp-apps/app-frame.js +17 -12
- package/dist/mcp-apps/app-frame.js.map +1 -1
- package/dist/mcp-apps/bridge.js +7 -1
- package/dist/mcp-apps/bridge.js.map +1 -1
- package/dist/primitives/assistantModal/AssistantModalRoot.d.ts.map +1 -1
- package/dist/primitives/assistantModal/AssistantModalRoot.js +47 -79
- package/dist/primitives/assistantModal/AssistantModalRoot.js.map +1 -1
- package/dist/primitives/composer/ComposerAttachmentDropzone.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerAttachmentDropzone.js +61 -138
- package/dist/primitives/composer/ComposerAttachmentDropzone.js.map +1 -1
- package/dist/primitives/composer/ComposerInput.js +3 -8
- package/dist/primitives/composer/ComposerInput.js.map +1 -1
- package/dist/primitives/composer/trigger/triggerKeyboardResource.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/triggerKeyboardResource.js +53 -61
- package/dist/primitives/composer/trigger/triggerKeyboardResource.js.map +1 -1
- package/dist/primitives/composer/trigger/triggerNavigationResource.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/triggerNavigationResource.js +127 -137
- package/dist/primitives/composer/trigger/triggerNavigationResource.js.map +1 -1
- package/dist/primitives/message/MessageParts.js +2 -14
- package/dist/primitives/message/MessageParts.js.map +1 -1
- package/dist/primitives/message/MessagePartsGrouped.d.ts +3 -3
- package/dist/primitives/message/MessagePartsGrouped.js +2 -2
- package/dist/primitives/message/MessagePartsGrouped.js.map +1 -1
- package/dist/primitives/messagePart/MessagePartText.d.ts +3 -1
- package/dist/primitives/messagePart/MessagePartText.d.ts.map +1 -1
- package/dist/primitives/messagePart/MessagePartText.js +13 -36
- package/dist/primitives/messagePart/MessagePartText.js.map +1 -1
- package/dist/primitives/reasoning/useScrollLock.js +3 -1
- package/dist/primitives/reasoning/useScrollLock.js.map +1 -1
- package/dist/sandbox-host/SandboxHost.js +22 -4
- package/dist/sandbox-host/SandboxHost.js.map +1 -1
- package/dist/unstable/useLiveCompletionAdapter.js +24 -2
- package/dist/unstable/useLiveCompletionAdapter.js.map +1 -1
- package/dist/unstable/useMentionAdapter.js +2 -2
- package/dist/unstable/useMentionAdapter.js.map +1 -1
- package/dist/unstable/useMessageStallDetection.d.ts +4 -5
- package/dist/unstable/useMessageStallDetection.d.ts.map +1 -1
- package/dist/unstable/useMessageStallDetection.js +16 -15
- package/dist/unstable/useMessageStallDetection.js.map +1 -1
- package/dist/unstable/webmcp/convertTools.d.ts.map +1 -1
- package/dist/unstable/webmcp/convertTools.js +54 -3
- package/dist/unstable/webmcp/convertTools.js.map +1 -1
- package/dist/unstable/webmcp/useWebMcpProvider.d.ts.map +1 -1
- package/dist/unstable/webmcp/useWebMcpProvider.js +2 -1
- package/dist/unstable/webmcp/useWebMcpProvider.js.map +1 -1
- package/dist/utils/Primitive.d.ts +14 -6
- package/dist/utils/Primitive.d.ts.map +1 -1
- package/dist/utils/Primitive.js +25 -6
- package/dist/utils/Primitive.js.map +1 -1
- package/dist/utils/getSelectionMessageId.d.ts.map +1 -1
- package/dist/utils/getSelectionMessageId.js +17 -3
- package/dist/utils/getSelectionMessageId.js.map +1 -1
- package/package.json +11 -11
- package/src/context/providers/ThreadViewportProvider.tsx +2 -2
- package/src/hooks/useToolCallElapsed.ts +3 -0
- package/src/index.ts +1 -1
- package/src/mcp-apps/app-frame.test.tsx +189 -0
- package/src/mcp-apps/app-frame.tsx +31 -22
- package/src/mcp-apps/bridge.test.ts +98 -0
- package/src/mcp-apps/bridge.ts +7 -1
- package/src/primitives/assistantModal/AssistantModalRoot.test.tsx +123 -0
- package/src/primitives/assistantModal/AssistantModalRoot.tsx +11 -31
- package/src/primitives/assistantModal/AssistantModalTrigger.test.tsx +1 -16
- package/src/primitives/composer/ComposerAttachmentDropzone.test.tsx +43 -0
- package/src/primitives/composer/ComposerAttachmentDropzone.tsx +2 -10
- package/src/primitives/composer/ComposerInput.test.tsx +22 -0
- package/src/primitives/composer/ComposerInput.tsx +5 -9
- package/src/primitives/composer/trigger/triggerKeyboardResource.ts +13 -7
- package/src/primitives/composer/trigger/triggerNavigationResource.ts +5 -3
- package/src/primitives/message/MessageParts.tsx +1 -21
- package/src/primitives/message/MessagePartsGrouped.test.tsx +93 -0
- package/src/primitives/message/MessagePartsGrouped.tsx +6 -6
- package/src/primitives/messagePart/MessagePartText.test.tsx +69 -0
- package/src/primitives/messagePart/MessagePartText.tsx +22 -9
- package/src/primitives/reasoning/useScrollLock.test.tsx +129 -0
- package/src/primitives/reasoning/useScrollLock.ts +15 -1
- package/src/primitives/thread/useThreadViewportAutoScroll.test.tsx +38 -1
- package/src/sandbox-host/SandboxHost.test.tsx +40 -0
- package/src/sandbox-host/SandboxHost.tsx +24 -4
- package/src/tests/MessageParts.rendererOptions.test.tsx +117 -0
- package/src/tests/toolCallTiming.test.tsx +55 -1
- package/src/unstable/useLiveCompletionAdapter.test.tsx +202 -1
- package/src/unstable/useLiveCompletionAdapter.ts +38 -8
- package/src/unstable/useMentionAdapter.test.tsx +47 -1
- package/src/unstable/useMentionAdapter.ts +2 -2
- package/src/unstable/useMessageStallDetection.ts +26 -19
- package/src/unstable/webmcp/convertTools.test.ts +181 -21
- package/src/unstable/webmcp/convertTools.ts +87 -7
- package/src/unstable/webmcp/useWebMcpProvider.ts +2 -4
- package/src/utils/Primitive.test.tsx +46 -1
- package/src/utils/Primitive.tsx +37 -7
- package/src/utils/getSelectionMessageId.test.ts +176 -1
- package/src/utils/getSelectionMessageId.ts +21 -3
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { renderHook } from "@testing-library/react";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { useScrollLock } from "./useScrollLock";
|
|
6
|
+
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
vi.useRealTimers();
|
|
9
|
+
vi.unstubAllGlobals();
|
|
10
|
+
document.documentElement.removeAttribute("style");
|
|
11
|
+
document.body.removeAttribute("style");
|
|
12
|
+
document.body.replaceChildren();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
/** jsdom reports zero for every box, so the widths a real browser would measure
|
|
16
|
+
* are stubbed on the element under test. */
|
|
17
|
+
function stubWidths(
|
|
18
|
+
element: HTMLElement,
|
|
19
|
+
{ offsetWidth, clientWidth }: { offsetWidth: number; clientWidth: number },
|
|
20
|
+
) {
|
|
21
|
+
Object.defineProperty(element, "offsetWidth", {
|
|
22
|
+
configurable: true,
|
|
23
|
+
value: offsetWidth,
|
|
24
|
+
});
|
|
25
|
+
Object.defineProperty(element, "clientWidth", {
|
|
26
|
+
configurable: true,
|
|
27
|
+
value: clientWidth,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function lockWithin(container: HTMLElement) {
|
|
32
|
+
const animated = document.createElement("div");
|
|
33
|
+
container.appendChild(animated);
|
|
34
|
+
const ref = { current: animated };
|
|
35
|
+
const { result } = renderHook(() => useScrollLock(ref, 200));
|
|
36
|
+
return result.current;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("useScrollLock", () => {
|
|
40
|
+
it("compensates for the scrollbar it hides on an element scroller", () => {
|
|
41
|
+
const scroller = document.createElement("div");
|
|
42
|
+
scroller.style.overflowY = "auto";
|
|
43
|
+
scroller.style.borderWidth = "0px";
|
|
44
|
+
scroller.style.paddingRight = "4px";
|
|
45
|
+
document.body.appendChild(scroller);
|
|
46
|
+
stubWidths(scroller, { offsetWidth: 306, clientWidth: 300 });
|
|
47
|
+
|
|
48
|
+
lockWithin(scroller)();
|
|
49
|
+
|
|
50
|
+
expect(scroller.style.scrollbarWidth).toBe("none");
|
|
51
|
+
expect(scroller.style.paddingRight).toBe("10px");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// The root element's offsetWidth already excludes the viewport scrollbar, so
|
|
55
|
+
// the element formula reports zero for it; without this the page's centered
|
|
56
|
+
// content shifts sideways for the length of the animation.
|
|
57
|
+
it("compensates when the scroller is the root element", () => {
|
|
58
|
+
const root = document.documentElement;
|
|
59
|
+
root.style.overflowY = "auto";
|
|
60
|
+
stubWidths(root, { offsetWidth: 1599, clientWidth: 1599 });
|
|
61
|
+
vi.stubGlobal("innerWidth", 1605);
|
|
62
|
+
|
|
63
|
+
lockWithin(document.body)();
|
|
64
|
+
|
|
65
|
+
expect(root.style.scrollbarWidth).toBe("none");
|
|
66
|
+
expect(root.style.paddingRight).toBe("6px");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// With `html { overflow: hidden }` the body scrolls on its own, so the
|
|
70
|
+
// viewport measure reports nothing and only the element formula sees the bar.
|
|
71
|
+
it("compensates when the body is the scroller rather than the viewport", () => {
|
|
72
|
+
const root = document.documentElement;
|
|
73
|
+
stubWidths(root, { offsetWidth: 1600, clientWidth: 1600 });
|
|
74
|
+
vi.stubGlobal("innerWidth", 1600);
|
|
75
|
+
const body = document.body;
|
|
76
|
+
body.style.overflowY = "auto";
|
|
77
|
+
body.style.borderWidth = "0px";
|
|
78
|
+
stubWidths(body, { offsetWidth: 1606, clientWidth: 1600 });
|
|
79
|
+
|
|
80
|
+
lockWithin(body)();
|
|
81
|
+
|
|
82
|
+
expect(body.style.paddingRight).toBe("6px");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Both can scroll at once, and the padding lands on the body, so it is the
|
|
86
|
+
// body's own gutter that has to be replaced rather than the viewport's.
|
|
87
|
+
it("pads a scrolling body by its own gutter, not the viewport's", () => {
|
|
88
|
+
const root = document.documentElement;
|
|
89
|
+
stubWidths(root, { offsetWidth: 1585, clientWidth: 1585 });
|
|
90
|
+
vi.stubGlobal("innerWidth", 1600);
|
|
91
|
+
const body = document.body;
|
|
92
|
+
body.style.overflowY = "auto";
|
|
93
|
+
body.style.borderWidth = "0px";
|
|
94
|
+
stubWidths(body, { offsetWidth: 1591, clientWidth: 1585 });
|
|
95
|
+
|
|
96
|
+
lockWithin(body)();
|
|
97
|
+
|
|
98
|
+
expect(body.style.paddingRight).toBe("6px");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("restores the padding it added once the animation is over", () => {
|
|
102
|
+
vi.useFakeTimers();
|
|
103
|
+
const scroller = document.createElement("div");
|
|
104
|
+
scroller.style.overflowY = "auto";
|
|
105
|
+
scroller.style.borderWidth = "0px";
|
|
106
|
+
document.body.appendChild(scroller);
|
|
107
|
+
stubWidths(scroller, { offsetWidth: 306, clientWidth: 300 });
|
|
108
|
+
|
|
109
|
+
lockWithin(scroller)();
|
|
110
|
+
expect(scroller.style.paddingRight).toBe("6px");
|
|
111
|
+
|
|
112
|
+
vi.advanceTimersByTime(200);
|
|
113
|
+
|
|
114
|
+
expect(scroller.style.paddingRight).toBe("");
|
|
115
|
+
expect(scroller.style.scrollbarWidth).toBe("");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("leaves the padding alone when no scrollbar takes space", () => {
|
|
119
|
+
const scroller = document.createElement("div");
|
|
120
|
+
scroller.style.overflowY = "auto";
|
|
121
|
+
scroller.style.borderWidth = "0px";
|
|
122
|
+
document.body.appendChild(scroller);
|
|
123
|
+
stubWidths(scroller, { offsetWidth: 300, clientWidth: 300 });
|
|
124
|
+
|
|
125
|
+
lockWithin(scroller)();
|
|
126
|
+
|
|
127
|
+
expect(scroller.style.paddingRight).toBe("");
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -71,11 +71,25 @@ export const useScrollLock = <T extends HTMLElement = HTMLElement>(
|
|
|
71
71
|
const paddingSide =
|
|
72
72
|
computed.direction === "rtl" ? "paddingLeft" : "paddingRight";
|
|
73
73
|
const previousPadding = scrollContainer.style[paddingSide];
|
|
74
|
-
const
|
|
74
|
+
const elementScrollbarSize =
|
|
75
75
|
scrollContainer.offsetWidth -
|
|
76
76
|
scrollContainer.clientWidth -
|
|
77
77
|
parseFloat(computed.borderLeftWidth) -
|
|
78
78
|
parseFloat(computed.borderRightWidth);
|
|
79
|
+
// A root element's offsetWidth already excludes the viewport scrollbar, so
|
|
80
|
+
// the element formula reports zero once the scroll propagates to the
|
|
81
|
+
// viewport, and only then does the viewport measure apply. A body that
|
|
82
|
+
// scrolls in its own right is measured by the element formula like any
|
|
83
|
+
// other scroller, whether or not the viewport is scrolling too.
|
|
84
|
+
const ownerDocument = scrollContainer.ownerDocument;
|
|
85
|
+
const isRootScroller =
|
|
86
|
+
scrollContainer === ownerDocument.documentElement ||
|
|
87
|
+
scrollContainer === ownerDocument.body;
|
|
88
|
+
const scrollbarSize =
|
|
89
|
+
isRootScroller && elementScrollbarSize <= 0
|
|
90
|
+
? (ownerDocument.defaultView?.innerWidth ?? 0) -
|
|
91
|
+
ownerDocument.documentElement.clientWidth
|
|
92
|
+
: elementScrollbarSize;
|
|
79
93
|
|
|
80
94
|
scrollContainer.style.scrollbarWidth = "none";
|
|
81
95
|
if (scrollbarSize > 0) {
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
act,
|
|
5
|
+
cleanup,
|
|
6
|
+
fireEvent,
|
|
7
|
+
render,
|
|
8
|
+
screen,
|
|
9
|
+
waitFor,
|
|
10
|
+
} from "@testing-library/react";
|
|
4
11
|
import {
|
|
5
12
|
afterAll,
|
|
6
13
|
afterEach,
|
|
@@ -17,6 +24,7 @@ import { useThreadViewport } from "../../context/react/ThreadViewportContext";
|
|
|
17
24
|
import * as MessagePrimitive from "../message";
|
|
18
25
|
import { ThreadPrimitiveMessages } from "./ThreadMessages";
|
|
19
26
|
import { ThreadPrimitiveRoot } from "./ThreadRoot";
|
|
27
|
+
import { ThreadPrimitiveScrollToBottom } from "./ThreadScrollToBottom";
|
|
20
28
|
import { ThreadPrimitiveViewport } from "./ThreadViewport";
|
|
21
29
|
import {
|
|
22
30
|
ExportedMessageRepository,
|
|
@@ -242,6 +250,35 @@ const DelayedThread = ({
|
|
|
242
250
|
};
|
|
243
251
|
|
|
244
252
|
describe("useThreadViewportAutoScroll", () => {
|
|
253
|
+
it("preserves smooth scrolling from a control outside the viewport", async () => {
|
|
254
|
+
render(
|
|
255
|
+
<SyncRuntimeProvider>
|
|
256
|
+
<Thread autoScroll={false} scrollToBottomOnInitialize={false} />
|
|
257
|
+
<ThreadPrimitiveScrollToBottom behavior="smooth">
|
|
258
|
+
Scroll to bottom
|
|
259
|
+
</ThreadPrimitiveScrollToBottom>
|
|
260
|
+
</SyncRuntimeProvider>,
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
const viewport = getViewport();
|
|
264
|
+
act(() => {
|
|
265
|
+
viewport.dispatchEvent(new Event("scroll"));
|
|
266
|
+
});
|
|
267
|
+
const button = screen.getByRole("button", { name: "Scroll to bottom" });
|
|
268
|
+
await waitFor(() => expect(button.hasAttribute("disabled")).toBe(false));
|
|
269
|
+
|
|
270
|
+
const scrollToSpy = vi.spyOn(viewport, "scrollTo");
|
|
271
|
+
try {
|
|
272
|
+
fireEvent.click(button);
|
|
273
|
+
expect(scrollToSpy).toHaveBeenCalledWith({
|
|
274
|
+
top: viewport.scrollHeight,
|
|
275
|
+
behavior: "smooth",
|
|
276
|
+
});
|
|
277
|
+
} finally {
|
|
278
|
+
scrollToSpy.mockRestore();
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
|
|
245
282
|
it("scrolls sync initialMessages to the bottom when the viewport mounts after initialization", async () => {
|
|
246
283
|
render(
|
|
247
284
|
<SyncRuntimeProvider>
|
|
@@ -327,6 +327,46 @@ describe("SandboxHost", () => {
|
|
|
327
327
|
expect(onMessage).not.toHaveBeenCalled();
|
|
328
328
|
});
|
|
329
329
|
|
|
330
|
+
it("disposes the frame when bridge cleanup throws", async () => {
|
|
331
|
+
const rendered = fakeRendered();
|
|
332
|
+
renderHtmlMock.mockResolvedValue(rendered);
|
|
333
|
+
const cleanupError = new Error("bridge cleanup failed");
|
|
334
|
+
const onMessage = vi.fn();
|
|
335
|
+
const bridge: SandboxBridge = {
|
|
336
|
+
onMessage,
|
|
337
|
+
dispose: vi.fn(() => {
|
|
338
|
+
throw cleanupError;
|
|
339
|
+
}),
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
await act(async () => {
|
|
343
|
+
root.render(
|
|
344
|
+
<SandboxHost
|
|
345
|
+
content={{ html: "" }}
|
|
346
|
+
contentKey="k"
|
|
347
|
+
createBridge={() => bridge}
|
|
348
|
+
/>,
|
|
349
|
+
);
|
|
350
|
+
});
|
|
351
|
+
await flush();
|
|
352
|
+
|
|
353
|
+
expect(() => {
|
|
354
|
+
act(() => root.unmount());
|
|
355
|
+
}).toThrow(cleanupError);
|
|
356
|
+
|
|
357
|
+
expect(bridge.dispose).toHaveBeenCalledOnce();
|
|
358
|
+
expect(rendered.dispose).toHaveBeenCalledOnce();
|
|
359
|
+
|
|
360
|
+
window.dispatchEvent(
|
|
361
|
+
new MessageEvent("message", {
|
|
362
|
+
data: validData,
|
|
363
|
+
origin: rendered.origin,
|
|
364
|
+
source: rendered.iframe.contentWindow,
|
|
365
|
+
}),
|
|
366
|
+
);
|
|
367
|
+
expect(onMessage).not.toHaveBeenCalled();
|
|
368
|
+
});
|
|
369
|
+
|
|
330
370
|
it("calls onError when rendering rejects", async () => {
|
|
331
371
|
renderHtmlMock.mockRejectedValue(new Error("boom"));
|
|
332
372
|
const onError = vi.fn();
|
|
@@ -193,15 +193,35 @@ export function SandboxHost({
|
|
|
193
193
|
|
|
194
194
|
return () => {
|
|
195
195
|
cancelled = true;
|
|
196
|
+
let cleanupFailed = false;
|
|
197
|
+
let cleanupError: unknown;
|
|
198
|
+
const runCleanup = (cleanup: () => void) => {
|
|
199
|
+
try {
|
|
200
|
+
cleanup();
|
|
201
|
+
} catch (error) {
|
|
202
|
+
if (cleanupFailed) {
|
|
203
|
+
console.error(error);
|
|
204
|
+
} else {
|
|
205
|
+
cleanupFailed = true;
|
|
206
|
+
cleanupError = error;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
|
|
196
211
|
if (onMessage) {
|
|
197
|
-
|
|
212
|
+
const listener = onMessage;
|
|
198
213
|
onMessage = null;
|
|
214
|
+
runCleanup(() => window.removeEventListener("message", listener));
|
|
199
215
|
}
|
|
200
|
-
bridge
|
|
216
|
+
const bridgeToDispose = bridge;
|
|
201
217
|
bridge = null;
|
|
202
|
-
|
|
218
|
+
if (bridgeToDispose) runCleanup(() => bridgeToDispose.dispose());
|
|
219
|
+
const frameToDispose = frame;
|
|
203
220
|
frame = null;
|
|
204
|
-
|
|
221
|
+
if (frameToDispose) runCleanup(() => frameToDispose.dispose());
|
|
222
|
+
runCleanup(() => setContentHeight(undefined));
|
|
223
|
+
|
|
224
|
+
if (cleanupFailed) throw cleanupError;
|
|
205
225
|
};
|
|
206
226
|
// oxlint-disable-next-line react/exhaustive-deps -- re-init only on contentKey change; live values flow through liveRef
|
|
207
227
|
}, [contentKey]);
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { act } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
6
|
+
import {
|
|
7
|
+
AssistantRuntimeProvider,
|
|
8
|
+
MessageByIndexProvider,
|
|
9
|
+
useExternalStoreRuntime,
|
|
10
|
+
} from "@assistant-ui/core/react";
|
|
11
|
+
import type { ThreadMessageLike } from "@assistant-ui/core";
|
|
12
|
+
import { MessagePrimitiveParts } from "../primitives/message/MessageParts";
|
|
13
|
+
|
|
14
|
+
(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
|
|
15
|
+
|
|
16
|
+
const messages: ThreadMessageLike[] = [
|
|
17
|
+
{
|
|
18
|
+
role: "assistant",
|
|
19
|
+
content: [
|
|
20
|
+
{ type: "text", text: "Answer" },
|
|
21
|
+
{ type: "reasoning", text: "Thinking" },
|
|
22
|
+
{
|
|
23
|
+
type: "generative-ui",
|
|
24
|
+
spec: {
|
|
25
|
+
root: [
|
|
26
|
+
{ component: "Card", props: { title: "Result" } },
|
|
27
|
+
{ component: "Unknown" },
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
{ type: "data", name: "chart", data: 42 },
|
|
32
|
+
{ type: "data", name: "other", data: 7 },
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
const components = {
|
|
38
|
+
generativeUI: {
|
|
39
|
+
components: {
|
|
40
|
+
Card: ({ title }: { title: string }) => <span>{title}</span>,
|
|
41
|
+
},
|
|
42
|
+
Fallback: ({ component }: { component: string }) => (
|
|
43
|
+
<span>Unavailable: {component}</span>
|
|
44
|
+
),
|
|
45
|
+
},
|
|
46
|
+
data: {
|
|
47
|
+
by_name: {
|
|
48
|
+
chart: ({ data }: { data: unknown }) => (
|
|
49
|
+
<span>Chart: {String(data)}</span>
|
|
50
|
+
),
|
|
51
|
+
},
|
|
52
|
+
Fallback: ({ data }: { data: unknown }) => (
|
|
53
|
+
<span>Data: {String(data)}</span>
|
|
54
|
+
),
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const App = (props: MessagePrimitiveParts.Props) => {
|
|
59
|
+
const runtime = useExternalStoreRuntime({
|
|
60
|
+
messages,
|
|
61
|
+
convertMessage: (message) => message,
|
|
62
|
+
onNew: async () => {
|
|
63
|
+
throw new Error("This thread is read-only");
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
return (
|
|
67
|
+
<AssistantRuntimeProvider runtime={runtime}>
|
|
68
|
+
<MessageByIndexProvider index={0}>
|
|
69
|
+
<MessagePrimitiveParts {...props} />
|
|
70
|
+
</MessageByIndexProvider>
|
|
71
|
+
</AssistantRuntimeProvider>
|
|
72
|
+
);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const ChainOfThought = () => <span>Thought group</span>;
|
|
76
|
+
|
|
77
|
+
describe("MessagePrimitiveParts", () => {
|
|
78
|
+
let container: HTMLDivElement;
|
|
79
|
+
let root: Root;
|
|
80
|
+
|
|
81
|
+
beforeEach(() => {
|
|
82
|
+
container = document.createElement("div");
|
|
83
|
+
document.body.appendChild(container);
|
|
84
|
+
root = createRoot(container);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
afterEach(async () => {
|
|
88
|
+
await act(async () => root.unmount());
|
|
89
|
+
container.remove();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("renders generative UI and its fallback beside web text", async () => {
|
|
93
|
+
await act(async () => root.render(<App components={components} />));
|
|
94
|
+
expect(container.textContent).toBe(
|
|
95
|
+
"AnswerResultUnavailable: UnknownChart: 42Data: 7",
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("keeps data and generative UI when chain-of-thought grouping changes", async () => {
|
|
100
|
+
await act(async () => root.render(<App components={components} />));
|
|
101
|
+
expect(container.textContent).toBe(
|
|
102
|
+
"AnswerResultUnavailable: UnknownChart: 42Data: 7",
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
await act(async () =>
|
|
106
|
+
root.render(<App components={{ ...components, ChainOfThought }} />),
|
|
107
|
+
);
|
|
108
|
+
expect(container.textContent).toBe(
|
|
109
|
+
"AnswerThought groupResultUnavailable: UnknownChart: 42Data: 7",
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
await act(async () => root.render(<App components={components} />));
|
|
113
|
+
expect(container.textContent).toBe(
|
|
114
|
+
"AnswerResultUnavailable: UnknownChart: 42Data: 7",
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -7,8 +7,10 @@ import { AssistantRuntimeProvider } from "../context";
|
|
|
7
7
|
import * as MessagePrimitive from "../primitives/message";
|
|
8
8
|
import * as ThreadPrimitive from "../primitives/thread";
|
|
9
9
|
import { useLocalRuntime } from "../legacy-runtime/runtime-cores/local/useLocalRuntime";
|
|
10
|
-
import type { ChatModelAdapter, ThreadMessageLike } from "../index";
|
|
11
10
|
import {
|
|
11
|
+
type ChatModelAdapter,
|
|
12
|
+
type ThreadMessageLike,
|
|
13
|
+
useExternalStoreRuntime,
|
|
12
14
|
useToolCallElapsed,
|
|
13
15
|
unstable_useMessageStallDetection,
|
|
14
16
|
} from "../index";
|
|
@@ -60,6 +62,22 @@ const RuntimeProvider: FC<
|
|
|
60
62
|
);
|
|
61
63
|
};
|
|
62
64
|
|
|
65
|
+
const ExternalRuntimeProvider: FC<
|
|
66
|
+
PropsWithChildren<{ messages: ThreadMessageLike[] }>
|
|
67
|
+
> = ({ messages, children }) => {
|
|
68
|
+
const runtime = useExternalStoreRuntime({
|
|
69
|
+
messages,
|
|
70
|
+
isRunning: true,
|
|
71
|
+
convertMessage: (message) => message,
|
|
72
|
+
onNew: async () => {},
|
|
73
|
+
});
|
|
74
|
+
return (
|
|
75
|
+
<AssistantRuntimeProvider runtime={runtime}>
|
|
76
|
+
{children}
|
|
77
|
+
</AssistantRuntimeProvider>
|
|
78
|
+
);
|
|
79
|
+
};
|
|
80
|
+
|
|
63
81
|
const renderHarness = async (messages: ThreadMessageLike[], probe: FC) => {
|
|
64
82
|
render(<Harness messages={messages} probe={probe} />);
|
|
65
83
|
// Flush the runtime's deferred initialization, which fake timers hold back.
|
|
@@ -83,6 +101,19 @@ const Harness: FC<{ messages: ThreadMessageLike[]; probe: FC }> = ({
|
|
|
83
101
|
</RuntimeProvider>
|
|
84
102
|
);
|
|
85
103
|
|
|
104
|
+
const ExternalHarness: FC<{
|
|
105
|
+
messages: ThreadMessageLike[];
|
|
106
|
+
probe: FC;
|
|
107
|
+
}> = ({ messages, probe: Probe }) => (
|
|
108
|
+
<ExternalRuntimeProvider messages={messages}>
|
|
109
|
+
<ThreadPrimitive.Messages
|
|
110
|
+
components={{
|
|
111
|
+
Message: () => <MessagePrimitive.Parts components={{ Text: Probe }} />,
|
|
112
|
+
}}
|
|
113
|
+
/>
|
|
114
|
+
</ExternalRuntimeProvider>
|
|
115
|
+
);
|
|
116
|
+
|
|
86
117
|
describe("useToolCallElapsed", () => {
|
|
87
118
|
beforeEach(() => {
|
|
88
119
|
vi.useFakeTimers();
|
|
@@ -218,4 +249,27 @@ describe("unstable_useMessageStallDetection", () => {
|
|
|
218
249
|
|
|
219
250
|
expect(screen.getByTestId("stalled").textContent).toBe("false");
|
|
220
251
|
});
|
|
252
|
+
|
|
253
|
+
it("resets the timer for equal-length content changes", async () => {
|
|
254
|
+
const message = (text: string): ThreadMessageLike => ({
|
|
255
|
+
role: "assistant",
|
|
256
|
+
content: [{ type: "text", text }],
|
|
257
|
+
status: { type: "running" },
|
|
258
|
+
});
|
|
259
|
+
const view = render(
|
|
260
|
+
<ExternalHarness messages={[message("first")]} probe={StallProbe} />,
|
|
261
|
+
);
|
|
262
|
+
await act(async () => vi.advanceTimersByTimeAsync(0));
|
|
263
|
+
|
|
264
|
+
await act(async () => vi.advanceTimersByTimeAsync(1500));
|
|
265
|
+
view.rerender(
|
|
266
|
+
<ExternalHarness messages={[message("other")]} probe={StallProbe} />,
|
|
267
|
+
);
|
|
268
|
+
await act(async () => vi.advanceTimersByTimeAsync(1000));
|
|
269
|
+
|
|
270
|
+
expect(screen.getByTestId("stalled").textContent).toBe("false");
|
|
271
|
+
|
|
272
|
+
await act(async () => vi.advanceTimersByTimeAsync(1000));
|
|
273
|
+
expect(screen.getByTestId("stalled").textContent).toBe("true");
|
|
274
|
+
});
|
|
221
275
|
});
|