@assistant-ui/react 0.15.17 → 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 +8 -2
- 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 +7 -4
- package/dist/index.js +5 -3
- package/dist/mcp-apps/McpAppRenderer.d.ts +30 -8
- package/dist/mcp-apps/McpAppRenderer.d.ts.map +1 -1
- package/dist/mcp-apps/McpAppRenderer.js +91 -47
- package/dist/mcp-apps/McpAppRenderer.js.map +1 -1
- package/dist/mcp-apps/app-frame.d.ts.map +1 -1
- package/dist/mcp-apps/app-frame.js +57 -33
- package/dist/mcp-apps/app-frame.js.map +1 -1
- package/dist/mcp-apps/bridge.js +9 -4
- package/dist/mcp-apps/bridge.js.map +1 -1
- package/dist/mcp-apps/index.d.ts +2 -2
- 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/ComposerInputPluginContext.d.ts +1 -2
- package/dist/primitives/composer/ComposerInputPluginContext.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerInputPluginContext.js +1 -6
- package/dist/primitives/composer/ComposerInputPluginContext.js.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverAction.js +46 -15
- package/dist/primitives/composer/trigger/TriggerPopoverAction.js.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverDirective.js +40 -10
- package/dist/primitives/composer/trigger/TriggerPopoverDirective.js.map +1 -1
- package/dist/primitives/composer/trigger/matchesTriggerItemQuery.d.ts +6 -0
- package/dist/primitives/composer/trigger/matchesTriggerItemQuery.d.ts.map +1 -0
- package/dist/primitives/composer/trigger/matchesTriggerItemQuery.js +9 -0
- package/dist/primitives/composer/trigger/matchesTriggerItemQuery.js.map +1 -0
- 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 +129 -141
- 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 +2 -0
- 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/primitives/thread/useThreadViewportAutoScroll.d.ts.map +1 -1
- package/dist/primitives/thread/useThreadViewportAutoScroll.js +3 -3
- package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
- package/dist/sandbox-host/SandboxHost.d.ts.map +1 -1
- package/dist/sandbox-host/SandboxHost.js +36 -9
- 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.d.ts +9 -3
- package/dist/unstable/useMentionAdapter.d.ts.map +1 -1
- package/dist/unstable/useMentionAdapter.js +50 -43
- 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/useModelContextSnapshot.d.ts +34 -0
- package/dist/unstable/useModelContextSnapshot.d.ts.map +1 -0
- package/dist/unstable/useModelContextSnapshot.js +61 -0
- package/dist/unstable/useModelContextSnapshot.js.map +1 -0
- package/dist/unstable/useSlashCommandAdapter.d.ts.map +1 -1
- package/dist/unstable/useSlashCommandAdapter.js +2 -8
- package/dist/unstable/useSlashCommandAdapter.js.map +1 -1
- package/dist/unstable/webmcp/WebMcpRegistrationResource.d.ts +14 -0
- package/dist/unstable/webmcp/WebMcpRegistrationResource.d.ts.map +1 -0
- package/dist/unstable/webmcp/WebMcpRegistrationResource.js +85 -0
- package/dist/unstable/webmcp/WebMcpRegistrationResource.js.map +1 -0
- package/dist/unstable/webmcp/convertTools.d.ts +21 -0
- package/dist/unstable/webmcp/convertTools.d.ts.map +1 -0
- package/dist/unstable/webmcp/convertTools.js +161 -0
- package/dist/unstable/webmcp/convertTools.js.map +1 -0
- package/dist/unstable/webmcp/useWebMcpProvider.d.ts +21 -0
- package/dist/unstable/webmcp/useWebMcpProvider.d.ts.map +1 -0
- package/dist/unstable/webmcp/useWebMcpProvider.js +106 -0
- package/dist/unstable/webmcp/useWebMcpProvider.js.map +1 -0
- package/dist/unstable/webmcp/webmcp-host.d.ts +39 -0
- package/dist/unstable/webmcp/webmcp-host.d.ts.map +1 -0
- package/dist/unstable/webmcp/webmcp-host.js +29 -0
- package/dist/unstable/webmcp/webmcp-host.js.map +1 -0
- package/dist/utils/Primitive.d.ts +10 -2
- 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 +13 -13
- package/src/context/providers/ThreadViewportProvider.tsx +2 -2
- package/src/hooks/useToolCallElapsed.ts +3 -0
- package/src/index.ts +14 -1
- package/src/mcp-apps/McpAppRenderer.test.tsx +253 -5
- package/src/mcp-apps/McpAppRenderer.tsx +147 -52
- package/src/mcp-apps/app-frame.test.tsx +437 -1
- package/src/mcp-apps/app-frame.tsx +76 -21
- package/src/mcp-apps/bridge.test.ts +98 -0
- package/src/mcp-apps/bridge.ts +14 -3
- package/src/mcp-apps/index.ts +5 -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/ComposerInputPluginContext.tsx +0 -10
- package/src/primitives/composer/trigger/TriggerPopoverAction.tsx +4 -2
- package/src/primitives/composer/trigger/TriggerPopoverBehavior.test.tsx +109 -0
- package/src/primitives/composer/trigger/TriggerPopoverDirective.tsx +4 -2
- package/src/primitives/composer/trigger/matchesTriggerItemQuery.test.ts +52 -0
- package/src/primitives/composer/trigger/matchesTriggerItemQuery.ts +13 -0
- package/src/primitives/composer/trigger/triggerKeyboardResource.ts +13 -7
- package/src/primitives/composer/trigger/triggerNavigationResource.ts +8 -13
- 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/primitives/thread/useThreadViewportAutoScroll.ts +7 -6
- package/src/sandbox-host/SandboxHost.test.tsx +160 -2
- package/src/sandbox-host/SandboxHost.tsx +50 -10
- package/src/tests/MessageParts.rendererOptions.test.tsx +117 -0
- package/src/tests/toolCallTiming.test.tsx +55 -1
- package/src/unstable/useComposerInputHistory.test.tsx +2 -1
- package/src/unstable/useLiveCompletionAdapter.test.tsx +202 -1
- package/src/unstable/useLiveCompletionAdapter.ts +38 -8
- package/src/unstable/useMentionAdapter.test.tsx +383 -0
- package/src/unstable/useMentionAdapter.ts +94 -57
- package/src/unstable/useMessageStallDetection.ts +26 -19
- package/src/unstable/useModelContextSnapshot.test.tsx +132 -0
- package/src/unstable/useModelContextSnapshot.ts +67 -0
- package/src/unstable/useSlashCommandAdapter.ts +2 -9
- package/src/unstable/webmcp/WebMcpRegistrationResource.ts +92 -0
- package/src/unstable/webmcp/__tests__/webmcp.fake.tsx +168 -0
- package/src/unstable/webmcp/convertTools.test.ts +553 -0
- package/src/unstable/webmcp/convertTools.ts +260 -0
- package/src/unstable/webmcp/useWebMcpProvider.test.tsx +466 -0
- package/src/unstable/webmcp/useWebMcpProvider.ts +148 -0
- package/src/unstable/webmcp/webmcp-host.test.ts +142 -0
- package/src/unstable/webmcp/webmcp-host.ts +83 -0
- 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
- package/dist/utils/invokeCallbackSafely.d.ts +0 -5
- package/dist/utils/invokeCallbackSafely.d.ts.map +0 -1
- package/dist/utils/invokeCallbackSafely.js +0 -15
- package/dist/utils/invokeCallbackSafely.js.map +0 -1
- package/dist/utils/useToolArgsFieldStatus.d.ts +0 -19
- package/dist/utils/useToolArgsFieldStatus.d.ts.map +0 -1
- package/dist/utils/useToolArgsFieldStatus.js +0 -23
- package/dist/utils/useToolArgsFieldStatus.js.map +0 -1
- package/src/utils/invokeCallbackSafely.ts +0 -11
- package/src/utils/useToolArgsFieldStatus.ts +0 -18
|
@@ -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>
|
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
import { useComposedRefs } from "radix-ui/internal";
|
|
4
4
|
import { useCallback, useLayoutEffect, useRef, type RefCallback } from "react";
|
|
5
5
|
import { useAuiEvent, useAuiState } from "@assistant-ui/store";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
isUserScrollUp,
|
|
8
|
+
isViewportAtBottom,
|
|
9
|
+
viewportOverflows,
|
|
10
|
+
} from "@assistant-ui/store/client";
|
|
7
11
|
import { useOnResizeContent } from "../../utils/hooks/useOnResizeContent";
|
|
8
12
|
import { useOnScrollToBottom } from "../../utils/hooks/useOnScrollToBottom";
|
|
9
13
|
import { useManagedRef } from "../../utils/hooks/useManagedRef";
|
|
@@ -114,9 +118,7 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
|
|
|
114
118
|
if (!div) return;
|
|
115
119
|
|
|
116
120
|
const isAtBottom = threadViewportStore.getState().isAtBottom;
|
|
117
|
-
const newIsAtBottom =
|
|
118
|
-
Math.abs(div.scrollHeight - div.scrollTop - div.clientHeight) <= 1 ||
|
|
119
|
-
div.scrollHeight <= div.clientHeight;
|
|
121
|
+
const newIsAtBottom = isViewportAtBottom(div);
|
|
120
122
|
|
|
121
123
|
const isInFlightDownwardScroll =
|
|
122
124
|
!newIsAtBottom && lastScrollTop.current < div.scrollTop;
|
|
@@ -135,8 +137,7 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
|
|
|
135
137
|
if (newIsAtBottom) {
|
|
136
138
|
// newIsAtBottom is ambiguous when the viewport doesn't overflow —
|
|
137
139
|
// keep intent alive until content can actually scroll
|
|
138
|
-
|
|
139
|
-
if (viewportOverflows) {
|
|
140
|
+
if (viewportOverflows(div)) {
|
|
140
141
|
scrollingToBottomBehaviorRef.current = null;
|
|
141
142
|
}
|
|
142
143
|
if (autoScroll) followBottomRef.current = true;
|
|
@@ -5,7 +5,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
5
5
|
|
|
6
6
|
const { renderHtmlMock } = vi.hoisted(() => ({ renderHtmlMock: vi.fn() }));
|
|
7
7
|
|
|
8
|
-
vi.mock("safe-content-frame", () => ({
|
|
8
|
+
vi.mock("safe-content-frame", async (importOriginal) => ({
|
|
9
|
+
...(await importOriginal<typeof import("safe-content-frame")>()),
|
|
9
10
|
SafeContentFrame: class {
|
|
10
11
|
renderHtml = renderHtmlMock;
|
|
11
12
|
},
|
|
@@ -34,7 +35,7 @@ function fakeRendered() {
|
|
|
34
35
|
origin: "https://fake.scf.test",
|
|
35
36
|
sendMessage: vi.fn(),
|
|
36
37
|
dispose: vi.fn(),
|
|
37
|
-
fullyLoadedPromiseWithTimeout: vi.fn(),
|
|
38
|
+
fullyLoadedPromiseWithTimeout: vi.fn(() => new Promise<void>(() => {})),
|
|
38
39
|
};
|
|
39
40
|
}
|
|
40
41
|
|
|
@@ -140,6 +141,123 @@ describe("SandboxHost", () => {
|
|
|
140
141
|
expect(onMessage).toHaveBeenCalledTimes(1);
|
|
141
142
|
});
|
|
142
143
|
|
|
144
|
+
it("reports a frame that never finishes loading through onError", async () => {
|
|
145
|
+
const rendered = fakeRendered();
|
|
146
|
+
rendered.fullyLoadedPromiseWithTimeout.mockImplementation(() =>
|
|
147
|
+
Promise.reject(
|
|
148
|
+
Object.assign(new Error("Failed to load shim: https://fake.scf.test"), {
|
|
149
|
+
code: "shim-unavailable",
|
|
150
|
+
}),
|
|
151
|
+
),
|
|
152
|
+
);
|
|
153
|
+
renderHtmlMock.mockResolvedValue(rendered);
|
|
154
|
+
const onError = vi.fn();
|
|
155
|
+
|
|
156
|
+
await act(async () => {
|
|
157
|
+
root.render(
|
|
158
|
+
<SandboxHost
|
|
159
|
+
content={{ html: "" }}
|
|
160
|
+
contentKey="k"
|
|
161
|
+
createBridge={() => ({ onMessage: vi.fn(), dispose: vi.fn() })}
|
|
162
|
+
onError={onError}
|
|
163
|
+
/>,
|
|
164
|
+
);
|
|
165
|
+
});
|
|
166
|
+
await flush();
|
|
167
|
+
|
|
168
|
+
expect(onError).toHaveBeenCalledTimes(1);
|
|
169
|
+
expect(onError.mock.calls[0]![0]).toBeInstanceOf(Error);
|
|
170
|
+
expect(onError.mock.calls[0]![0]).toMatchObject({
|
|
171
|
+
code: "shim-unavailable",
|
|
172
|
+
message: "Failed to load shim: https://fake.scf.test",
|
|
173
|
+
});
|
|
174
|
+
expect(rendered.dispose).not.toHaveBeenCalled();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("stays silent when the shim started and the render is merely slow", async () => {
|
|
178
|
+
const rendered = fakeRendered();
|
|
179
|
+
rendered.fullyLoadedPromiseWithTimeout.mockImplementation(() =>
|
|
180
|
+
Promise.reject(
|
|
181
|
+
Object.assign(new Error("Timeout"), { code: "render-timeout" }),
|
|
182
|
+
),
|
|
183
|
+
);
|
|
184
|
+
renderHtmlMock.mockResolvedValue(rendered);
|
|
185
|
+
const onError = vi.fn();
|
|
186
|
+
|
|
187
|
+
await act(async () => {
|
|
188
|
+
root.render(
|
|
189
|
+
<SandboxHost
|
|
190
|
+
content={{ html: "" }}
|
|
191
|
+
contentKey="k"
|
|
192
|
+
createBridge={() => ({ onMessage: vi.fn(), dispose: vi.fn() })}
|
|
193
|
+
onError={onError}
|
|
194
|
+
/>,
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
await flush();
|
|
198
|
+
|
|
199
|
+
expect(rendered.fullyLoadedPromiseWithTimeout).toHaveBeenCalled();
|
|
200
|
+
expect(onError).not.toHaveBeenCalled();
|
|
201
|
+
expect(rendered.dispose).not.toHaveBeenCalled();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("reports a load failure that carries no shim code", async () => {
|
|
205
|
+
const rendered = fakeRendered();
|
|
206
|
+
rendered.fullyLoadedPromiseWithTimeout.mockImplementation(() =>
|
|
207
|
+
Promise.reject(new Error("Failed to load iframe")),
|
|
208
|
+
);
|
|
209
|
+
renderHtmlMock.mockResolvedValue(rendered);
|
|
210
|
+
const onError = vi.fn();
|
|
211
|
+
|
|
212
|
+
await act(async () => {
|
|
213
|
+
root.render(
|
|
214
|
+
<SandboxHost
|
|
215
|
+
content={{ html: "" }}
|
|
216
|
+
contentKey="k"
|
|
217
|
+
createBridge={() => ({ onMessage: vi.fn(), dispose: vi.fn() })}
|
|
218
|
+
onError={onError}
|
|
219
|
+
/>,
|
|
220
|
+
);
|
|
221
|
+
});
|
|
222
|
+
await flush();
|
|
223
|
+
|
|
224
|
+
expect(onError).toHaveBeenCalledTimes(1);
|
|
225
|
+
expect(onError.mock.calls[0]![0].message).toBe("Failed to load iframe");
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("does not report a load failure after unmount", async () => {
|
|
229
|
+
const rendered = fakeRendered();
|
|
230
|
+
let rejectLoad: (error: Error) => void;
|
|
231
|
+
rendered.fullyLoadedPromiseWithTimeout.mockReturnValue(
|
|
232
|
+
new Promise<void>((_, reject) => {
|
|
233
|
+
rejectLoad = reject;
|
|
234
|
+
}),
|
|
235
|
+
);
|
|
236
|
+
renderHtmlMock.mockResolvedValue(rendered);
|
|
237
|
+
const onError = vi.fn();
|
|
238
|
+
|
|
239
|
+
await act(async () => {
|
|
240
|
+
root.render(
|
|
241
|
+
<SandboxHost
|
|
242
|
+
content={{ html: "" }}
|
|
243
|
+
contentKey="k"
|
|
244
|
+
createBridge={() => ({ onMessage: vi.fn(), dispose: vi.fn() })}
|
|
245
|
+
onError={onError}
|
|
246
|
+
/>,
|
|
247
|
+
);
|
|
248
|
+
});
|
|
249
|
+
await flush();
|
|
250
|
+
|
|
251
|
+
await act(async () => {
|
|
252
|
+
root.unmount();
|
|
253
|
+
});
|
|
254
|
+
rejectLoad!(new Error("Timeout"));
|
|
255
|
+
await flush();
|
|
256
|
+
|
|
257
|
+
expect(rendered.fullyLoadedPromiseWithTimeout).toHaveBeenCalled();
|
|
258
|
+
expect(onError).not.toHaveBeenCalled();
|
|
259
|
+
});
|
|
260
|
+
|
|
143
261
|
it("clamps the bridge-reported height to maxHeight and ignores invalid values", async () => {
|
|
144
262
|
const rendered = fakeRendered();
|
|
145
263
|
renderHtmlMock.mockResolvedValue(rendered);
|
|
@@ -209,6 +327,46 @@ describe("SandboxHost", () => {
|
|
|
209
327
|
expect(onMessage).not.toHaveBeenCalled();
|
|
210
328
|
});
|
|
211
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
|
+
|
|
212
370
|
it("calls onError when rendering rejects", async () => {
|
|
213
371
|
renderHtmlMock.mockRejectedValue(new Error("boom"));
|
|
214
372
|
const onError = vi.fn();
|
|
@@ -8,14 +8,16 @@ import {
|
|
|
8
8
|
useState,
|
|
9
9
|
} from "react";
|
|
10
10
|
import {
|
|
11
|
+
isShimLoadError,
|
|
11
12
|
type RenderedFrame,
|
|
12
13
|
SafeContentFrame,
|
|
13
14
|
type SandboxOption,
|
|
14
15
|
} from "safe-content-frame";
|
|
15
|
-
import {
|
|
16
|
+
import { invokeUserCallback } from "@assistant-ui/core/internal";
|
|
16
17
|
|
|
17
18
|
const DEFAULT_PRODUCT = "assistant-ui-sandbox";
|
|
18
19
|
const DEFAULT_MAX_HEIGHT = 800;
|
|
20
|
+
const LOAD_TIMEOUT_MS = 10_000;
|
|
19
21
|
|
|
20
22
|
export type SandboxHostConfig = {
|
|
21
23
|
sandbox?: SandboxOption[];
|
|
@@ -108,6 +110,16 @@ export function SandboxHost({
|
|
|
108
110
|
|
|
109
111
|
const { content: liveContent, sandbox: sb } = liveRef.current;
|
|
110
112
|
|
|
113
|
+
const reportError = (err: unknown) => {
|
|
114
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
115
|
+
invokeUserCallback(
|
|
116
|
+
"assistant-ui",
|
|
117
|
+
"SandboxHost onError",
|
|
118
|
+
liveRef.current.onError?.bind(liveRef.current),
|
|
119
|
+
error,
|
|
120
|
+
);
|
|
121
|
+
};
|
|
122
|
+
|
|
111
123
|
const scf = new SafeContentFrame(sb?.product ?? DEFAULT_PRODUCT, {
|
|
112
124
|
...(sb?.sandbox !== undefined && { sandbox: sb.sandbox }),
|
|
113
125
|
...(sb?.useShadowDom !== undefined && { useShadowDom: sb.useShadowDom }),
|
|
@@ -159,29 +171,57 @@ export function SandboxHost({
|
|
|
159
171
|
bridge?.onMessage(event);
|
|
160
172
|
};
|
|
161
173
|
window.addEventListener("message", onMessage);
|
|
174
|
+
|
|
175
|
+
// renderHtml resolves at iframe load, which a shim that was never
|
|
176
|
+
// served also reaches, so a completed render is only known once the
|
|
177
|
+
// frame says so. render-timeout is excluded because the shim is
|
|
178
|
+
// running and the guest may still paint, and reporting it would leave
|
|
179
|
+
// a host that renders an error state showing one over a frame that
|
|
180
|
+
// recovers.
|
|
181
|
+
rendered.fullyLoadedPromiseWithTimeout(LOAD_TIMEOUT_MS).catch((err) => {
|
|
182
|
+
if (cancelled) return;
|
|
183
|
+
if (isShimLoadError(err) && err.code === "render-timeout") return;
|
|
184
|
+
reportError(err);
|
|
185
|
+
});
|
|
162
186
|
})
|
|
163
187
|
.catch((err) => {
|
|
164
188
|
if (cancelled) return;
|
|
165
189
|
frame?.dispose();
|
|
166
190
|
frame = null;
|
|
167
|
-
|
|
168
|
-
invokeCallbackSafely(
|
|
169
|
-
() => liveRef.current.onError?.(error),
|
|
170
|
-
"SandboxHost onError",
|
|
171
|
-
);
|
|
191
|
+
reportError(err);
|
|
172
192
|
});
|
|
173
193
|
|
|
174
194
|
return () => {
|
|
175
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
|
+
|
|
176
211
|
if (onMessage) {
|
|
177
|
-
|
|
212
|
+
const listener = onMessage;
|
|
178
213
|
onMessage = null;
|
|
214
|
+
runCleanup(() => window.removeEventListener("message", listener));
|
|
179
215
|
}
|
|
180
|
-
bridge
|
|
216
|
+
const bridgeToDispose = bridge;
|
|
181
217
|
bridge = null;
|
|
182
|
-
|
|
218
|
+
if (bridgeToDispose) runCleanup(() => bridgeToDispose.dispose());
|
|
219
|
+
const frameToDispose = frame;
|
|
183
220
|
frame = null;
|
|
184
|
-
|
|
221
|
+
if (frameToDispose) runCleanup(() => frameToDispose.dispose());
|
|
222
|
+
runCleanup(() => setContentHeight(undefined));
|
|
223
|
+
|
|
224
|
+
if (cleanupFailed) throw cleanupError;
|
|
185
225
|
};
|
|
186
226
|
// oxlint-disable-next-line react/exhaustive-deps -- re-init only on contentKey change; live values flow through liveRef
|
|
187
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
|
+
});
|