@assistant-ui/react 0.15.17 → 0.15.18
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 +1 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.js +4 -2
- 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 +41 -22
- package/dist/mcp-apps/app-frame.js.map +1 -1
- package/dist/mcp-apps/bridge.js +2 -3
- package/dist/mcp-apps/bridge.js.map +1 -1
- package/dist/mcp-apps/index.d.ts +2 -2
- 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/triggerNavigationResource.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/triggerNavigationResource.js +3 -5
- package/dist/primitives/composer/trigger/triggerNavigationResource.js.map +1 -1
- package/dist/primitives/messagePart/MessagePartText.d.ts +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 +14 -5
- package/dist/sandbox-host/SandboxHost.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/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 +110 -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 +105 -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 +4 -4
- package/package.json +12 -12
- package/src/index.ts +13 -0
- 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 +248 -1
- package/src/mcp-apps/app-frame.tsx +51 -5
- package/src/mcp-apps/bridge.ts +7 -2
- package/src/mcp-apps/index.ts +5 -1
- 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/triggerNavigationResource.ts +3 -10
- package/src/primitives/thread/useThreadViewportAutoScroll.ts +7 -6
- package/src/sandbox-host/SandboxHost.test.tsx +120 -2
- package/src/sandbox-host/SandboxHost.tsx +26 -6
- package/src/unstable/useComposerInputHistory.test.tsx +2 -1
- package/src/unstable/useMentionAdapter.test.tsx +337 -0
- package/src/unstable/useMentionAdapter.ts +94 -57
- 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 +393 -0
- package/src/unstable/webmcp/convertTools.ts +180 -0
- package/src/unstable/webmcp/useWebMcpProvider.test.tsx +466 -0
- package/src/unstable/webmcp/useWebMcpProvider.ts +150 -0
- package/src/unstable/webmcp/webmcp-host.test.ts +142 -0
- package/src/unstable/webmcp/webmcp-host.ts +83 -0
- 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
package/src/mcp-apps/bridge.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { SandboxHostFrame } from "../sandbox-host/SandboxHost";
|
|
2
|
-
import {
|
|
2
|
+
import { invokeUserCallback } from "@assistant-ui/core/internal";
|
|
3
3
|
import {
|
|
4
4
|
MCP_APP_PROTOCOL_VERSION,
|
|
5
5
|
type McpAppBridgeHandlers,
|
|
@@ -125,7 +125,12 @@ export function createMcpAppBridge(
|
|
|
125
125
|
};
|
|
126
126
|
|
|
127
127
|
const reportError = (error: Error) => {
|
|
128
|
-
|
|
128
|
+
invokeUserCallback(
|
|
129
|
+
"assistant-ui",
|
|
130
|
+
"MCP App onError",
|
|
131
|
+
handlers.onError?.bind(handlers),
|
|
132
|
+
error,
|
|
133
|
+
);
|
|
129
134
|
};
|
|
130
135
|
|
|
131
136
|
const handleRequest = async (req: McpAppJsonRpcRequest) => {
|
package/src/mcp-apps/index.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export {
|
|
2
|
+
McpAppRenderer,
|
|
3
|
+
type McpAppPartOptions,
|
|
4
|
+
type McpAppRendererOptions,
|
|
5
|
+
} from "./McpAppRenderer";
|
|
2
6
|
export { McpAppsRemoteHost } from "./McpAppsRemoteHost";
|
|
3
7
|
export { getMcpAppFromToolPart } from "./utils";
|
|
4
8
|
export type {
|
|
@@ -51,16 +51,6 @@ export type ComposerInputPluginRegistry = {
|
|
|
51
51
|
const ComposerInputPluginRegistryContext =
|
|
52
52
|
createContext<ComposerInputPluginRegistry | null>(null);
|
|
53
53
|
|
|
54
|
-
export const useComposerInputPluginRegistry =
|
|
55
|
-
(): ComposerInputPluginRegistry => {
|
|
56
|
-
const ctx = useContext(ComposerInputPluginRegistryContext);
|
|
57
|
-
if (!ctx)
|
|
58
|
-
throw new Error(
|
|
59
|
-
"useComposerInputPluginRegistry must be used within a ComposerInputPluginProvider",
|
|
60
|
-
);
|
|
61
|
-
return ctx;
|
|
62
|
-
};
|
|
63
|
-
|
|
64
54
|
export const useComposerInputPluginRegistryOptional =
|
|
65
55
|
(): ComposerInputPluginRegistry | null => {
|
|
66
56
|
return useContext(ComposerInputPluginRegistryContext);
|
|
@@ -5,7 +5,7 @@ import type {
|
|
|
5
5
|
Unstable_TriggerItem,
|
|
6
6
|
} from "@assistant-ui/core";
|
|
7
7
|
import { unstable_defaultDirectiveFormatter } from "@assistant-ui/core";
|
|
8
|
-
import { useEffect, useRef, type FC } from "react";
|
|
8
|
+
import { useEffect, useInsertionEffect, useRef, type FC } from "react";
|
|
9
9
|
import { useTriggerBehaviorRegistration } from "./TriggerPopover";
|
|
10
10
|
import type { TriggerBehavior } from "./triggerSelectionResource";
|
|
11
11
|
|
|
@@ -42,7 +42,9 @@ export const ComposerPrimitiveTriggerPopoverAction: FC<
|
|
|
42
42
|
> = ({ formatter, onExecute, removeOnExecute }) => {
|
|
43
43
|
const { register } = useTriggerBehaviorRegistration();
|
|
44
44
|
const onExecuteRef = useRef(onExecute);
|
|
45
|
-
|
|
45
|
+
useInsertionEffect(() => {
|
|
46
|
+
onExecuteRef.current = onExecute;
|
|
47
|
+
}, [onExecute]);
|
|
46
48
|
|
|
47
49
|
useEffect(() => {
|
|
48
50
|
const behavior: TriggerBehavior = {
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { Suspense, type ReactNode } from "react";
|
|
4
|
+
import { render, waitFor } from "@testing-library/react";
|
|
5
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import type { Unstable_TriggerItem } from "@assistant-ui/core";
|
|
7
|
+
import type { TriggerBehavior } from "./triggerSelectionResource";
|
|
8
|
+
|
|
9
|
+
const mocks = vi.hoisted(() => ({ register: vi.fn() }));
|
|
10
|
+
|
|
11
|
+
vi.mock("./TriggerPopover", async (importOriginal) => ({
|
|
12
|
+
...(await importOriginal<typeof import("./TriggerPopover")>()),
|
|
13
|
+
useTriggerBehaviorRegistration: () => ({ register: mocks.register }),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
import { ComposerPrimitiveTriggerPopoverAction } from "./TriggerPopoverAction";
|
|
17
|
+
import { ComposerPrimitiveTriggerPopoverDirective } from "./TriggerPopoverDirective";
|
|
18
|
+
|
|
19
|
+
const item: Unstable_TriggerItem = {
|
|
20
|
+
id: "item-1",
|
|
21
|
+
type: "test",
|
|
22
|
+
label: "Item",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const verifyCommittedCallback = async (
|
|
26
|
+
renderBehavior: (callback: (item: Unstable_TriggerItem) => void) => ReactNode,
|
|
27
|
+
invoke: (behavior: TriggerBehavior) => void,
|
|
28
|
+
) => {
|
|
29
|
+
let behavior: TriggerBehavior | undefined;
|
|
30
|
+
mocks.register.mockImplementation((next: TriggerBehavior) => {
|
|
31
|
+
behavior = next;
|
|
32
|
+
return () => {};
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
let suspend = false;
|
|
36
|
+
const suspended = new Promise<never>(() => {});
|
|
37
|
+
const renderProbe = vi.fn();
|
|
38
|
+
const Suspender = () => {
|
|
39
|
+
if (suspend) throw suspended;
|
|
40
|
+
return null;
|
|
41
|
+
};
|
|
42
|
+
const Probe = () => {
|
|
43
|
+
renderProbe();
|
|
44
|
+
return null;
|
|
45
|
+
};
|
|
46
|
+
const View = ({
|
|
47
|
+
callback,
|
|
48
|
+
}: {
|
|
49
|
+
callback: (item: Unstable_TriggerItem) => void;
|
|
50
|
+
}) => (
|
|
51
|
+
<Suspense fallback={null}>
|
|
52
|
+
{renderBehavior(callback)}
|
|
53
|
+
<Probe />
|
|
54
|
+
<Suspender />
|
|
55
|
+
</Suspense>
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const committed = vi.fn();
|
|
59
|
+
const abandoned = vi.fn();
|
|
60
|
+
const view = render(<View callback={committed} />);
|
|
61
|
+
|
|
62
|
+
await waitFor(() => expect(behavior).toBeDefined());
|
|
63
|
+
renderProbe.mockClear();
|
|
64
|
+
|
|
65
|
+
suspend = true;
|
|
66
|
+
view.rerender(<View callback={abandoned} />);
|
|
67
|
+
await waitFor(() => expect(renderProbe).toHaveBeenCalled());
|
|
68
|
+
|
|
69
|
+
invoke(behavior!);
|
|
70
|
+
expect(committed).toHaveBeenCalledExactlyOnceWith(item);
|
|
71
|
+
expect(abandoned).not.toHaveBeenCalled();
|
|
72
|
+
|
|
73
|
+
suspend = false;
|
|
74
|
+
view.rerender(<View callback={abandoned} />);
|
|
75
|
+
invoke(behavior!);
|
|
76
|
+
expect(abandoned).toHaveBeenCalledExactlyOnceWith(item);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
describe("TriggerPopover behavior callbacks", () => {
|
|
80
|
+
beforeEach(() => {
|
|
81
|
+
mocks.register.mockReset();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("keeps the committed action callback during abandoned renders", async () => {
|
|
85
|
+
await verifyCommittedCallback(
|
|
86
|
+
(onExecute) => (
|
|
87
|
+
<ComposerPrimitiveTriggerPopoverAction onExecute={onExecute} />
|
|
88
|
+
),
|
|
89
|
+
(behavior) => {
|
|
90
|
+
if (behavior.kind !== "action") throw new Error("Expected action");
|
|
91
|
+
behavior.onExecute(item);
|
|
92
|
+
},
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("keeps the committed directive callback during abandoned renders", async () => {
|
|
97
|
+
await verifyCommittedCallback(
|
|
98
|
+
(onInserted) => (
|
|
99
|
+
<ComposerPrimitiveTriggerPopoverDirective onInserted={onInserted} />
|
|
100
|
+
),
|
|
101
|
+
(behavior) => {
|
|
102
|
+
if (behavior.kind !== "directive") {
|
|
103
|
+
throw new Error("Expected directive");
|
|
104
|
+
}
|
|
105
|
+
behavior.onInserted?.(item);
|
|
106
|
+
},
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -5,7 +5,7 @@ import type {
|
|
|
5
5
|
Unstable_TriggerItem,
|
|
6
6
|
} from "@assistant-ui/core";
|
|
7
7
|
import { unstable_defaultDirectiveFormatter } from "@assistant-ui/core";
|
|
8
|
-
import { useEffect, useRef, type FC } from "react";
|
|
8
|
+
import { useEffect, useInsertionEffect, useRef, type FC } from "react";
|
|
9
9
|
import { useTriggerBehaviorRegistration } from "./TriggerPopover";
|
|
10
10
|
import type { TriggerBehavior } from "./triggerSelectionResource";
|
|
11
11
|
|
|
@@ -39,7 +39,9 @@ export const ComposerPrimitiveTriggerPopoverDirective: FC<
|
|
|
39
39
|
> = ({ formatter, onInserted }) => {
|
|
40
40
|
const { register } = useTriggerBehaviorRegistration();
|
|
41
41
|
const onInsertedRef = useRef(onInserted);
|
|
42
|
-
|
|
42
|
+
useInsertionEffect(() => {
|
|
43
|
+
onInsertedRef.current = onInserted;
|
|
44
|
+
}, [onInserted]);
|
|
43
45
|
|
|
44
46
|
useEffect(() => {
|
|
45
47
|
const behavior: TriggerBehavior = {
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { matchesTriggerItemQuery } from "./matchesTriggerItemQuery";
|
|
3
|
+
import type { Unstable_TriggerItem } from "@assistant-ui/core";
|
|
4
|
+
|
|
5
|
+
const item = (
|
|
6
|
+
overrides: Partial<Unstable_TriggerItem>,
|
|
7
|
+
): Unstable_TriggerItem => ({
|
|
8
|
+
id: "summarize",
|
|
9
|
+
type: "command",
|
|
10
|
+
label: "Summarize",
|
|
11
|
+
...overrides,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe("matchesTriggerItemQuery", () => {
|
|
15
|
+
it("matches every item on an empty query", () => {
|
|
16
|
+
expect(matchesTriggerItemQuery(item({}), "")).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("matches case-insensitively against the id", () => {
|
|
20
|
+
expect(
|
|
21
|
+
matchesTriggerItemQuery(item({ id: "SummArize", label: "y" }), "marize"),
|
|
22
|
+
).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("matches case-insensitively against the label", () => {
|
|
26
|
+
expect(
|
|
27
|
+
matchesTriggerItemQuery(
|
|
28
|
+
item({ id: "x", label: "Translate Text" }),
|
|
29
|
+
"late",
|
|
30
|
+
),
|
|
31
|
+
).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("matches against the description when present", () => {
|
|
35
|
+
expect(
|
|
36
|
+
matchesTriggerItemQuery(
|
|
37
|
+
item({ id: "x", label: "y", description: "Condense the thread" }),
|
|
38
|
+
"condense",
|
|
39
|
+
),
|
|
40
|
+
).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("does not match when no field contains the query", () => {
|
|
44
|
+
expect(
|
|
45
|
+
matchesTriggerItemQuery(item({ description: "nothing here" }), "zzz"),
|
|
46
|
+
).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("does not throw on items without a description", () => {
|
|
50
|
+
expect(matchesTriggerItemQuery(item({}), "zzz")).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Unstable_TriggerItem } from "@assistant-ui/core";
|
|
2
|
+
|
|
3
|
+
export function matchesTriggerItemQuery(
|
|
4
|
+
item: Unstable_TriggerItem,
|
|
5
|
+
lowerQuery: string,
|
|
6
|
+
): boolean {
|
|
7
|
+
if (!lowerQuery) return true;
|
|
8
|
+
return (
|
|
9
|
+
item.id.toLowerCase().includes(lowerQuery) ||
|
|
10
|
+
item.label.toLowerCase().includes(lowerQuery) ||
|
|
11
|
+
(item.description?.toLowerCase().includes(lowerQuery) ?? false)
|
|
12
|
+
);
|
|
13
|
+
}
|
|
@@ -5,14 +5,7 @@ import type {
|
|
|
5
5
|
Unstable_TriggerCategory,
|
|
6
6
|
Unstable_TriggerItem,
|
|
7
7
|
} from "@assistant-ui/core";
|
|
8
|
-
|
|
9
|
-
function matchesQuery(item: Unstable_TriggerItem, lower: string): boolean {
|
|
10
|
-
return (
|
|
11
|
-
item.id.toLowerCase().includes(lower) ||
|
|
12
|
-
item.label.toLowerCase().includes(lower) ||
|
|
13
|
-
(item.description?.toLowerCase().includes(lower) ?? false)
|
|
14
|
-
);
|
|
15
|
-
}
|
|
8
|
+
import { matchesTriggerItemQuery } from "./matchesTriggerItemQuery";
|
|
16
9
|
|
|
17
10
|
export type TriggerNavigationResourceOutput = {
|
|
18
11
|
/** Filtered categories visible in the list (empty in search mode). */
|
|
@@ -76,7 +69,7 @@ const useTriggerNavigationResource = ({
|
|
|
76
69
|
const lower = query.toLowerCase();
|
|
77
70
|
for (const cat of categories) {
|
|
78
71
|
for (const item of adapter.categoryItems(cat.id)) {
|
|
79
|
-
if (
|
|
72
|
+
if (matchesTriggerItemQuery(item, lower)) {
|
|
80
73
|
all.push(item);
|
|
81
74
|
}
|
|
82
75
|
}
|
|
@@ -97,7 +90,7 @@ const useTriggerNavigationResource = ({
|
|
|
97
90
|
if (isSearchMode) return searchResults ?? [];
|
|
98
91
|
if (!query) return allItems;
|
|
99
92
|
const lower = query.toLowerCase();
|
|
100
|
-
return allItems.filter((item) =>
|
|
93
|
+
return allItems.filter((item) => matchesTriggerItemQuery(item, lower));
|
|
101
94
|
}, [allItems, query, isSearchMode, searchResults]);
|
|
102
95
|
|
|
103
96
|
const navigableList = useMemo(() => {
|
|
@@ -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);
|
|
@@ -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,16 +171,24 @@ 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 () => {
|
|
@@ -23,7 +23,8 @@ vi.mock("@assistant-ui/store", () => ({
|
|
|
23
23
|
},
|
|
24
24
|
}),
|
|
25
25
|
}));
|
|
26
|
-
vi.mock("@assistant-ui/tap", () => ({
|
|
26
|
+
vi.mock("@assistant-ui/tap", async (importOriginal) => ({
|
|
27
|
+
...(await importOriginal<typeof import("@assistant-ui/tap")>()),
|
|
27
28
|
flushTapSync: (fn: () => void) => fn(),
|
|
28
29
|
}));
|
|
29
30
|
vi.mock("../primitives/composer/trigger/TriggerPopoverRootContext", () => ({
|