@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.
Files changed (105) hide show
  1. package/README.md +1 -1
  2. package/dist/index.d.ts +6 -3
  3. package/dist/index.js +4 -2
  4. package/dist/mcp-apps/McpAppRenderer.d.ts +30 -8
  5. package/dist/mcp-apps/McpAppRenderer.d.ts.map +1 -1
  6. package/dist/mcp-apps/McpAppRenderer.js +91 -47
  7. package/dist/mcp-apps/McpAppRenderer.js.map +1 -1
  8. package/dist/mcp-apps/app-frame.d.ts.map +1 -1
  9. package/dist/mcp-apps/app-frame.js +41 -22
  10. package/dist/mcp-apps/app-frame.js.map +1 -1
  11. package/dist/mcp-apps/bridge.js +2 -3
  12. package/dist/mcp-apps/bridge.js.map +1 -1
  13. package/dist/mcp-apps/index.d.ts +2 -2
  14. package/dist/primitives/composer/ComposerInputPluginContext.d.ts +1 -2
  15. package/dist/primitives/composer/ComposerInputPluginContext.d.ts.map +1 -1
  16. package/dist/primitives/composer/ComposerInputPluginContext.js +1 -6
  17. package/dist/primitives/composer/ComposerInputPluginContext.js.map +1 -1
  18. package/dist/primitives/composer/trigger/TriggerPopoverAction.js +46 -15
  19. package/dist/primitives/composer/trigger/TriggerPopoverAction.js.map +1 -1
  20. package/dist/primitives/composer/trigger/TriggerPopoverDirective.js +40 -10
  21. package/dist/primitives/composer/trigger/TriggerPopoverDirective.js.map +1 -1
  22. package/dist/primitives/composer/trigger/matchesTriggerItemQuery.d.ts +6 -0
  23. package/dist/primitives/composer/trigger/matchesTriggerItemQuery.d.ts.map +1 -0
  24. package/dist/primitives/composer/trigger/matchesTriggerItemQuery.js +9 -0
  25. package/dist/primitives/composer/trigger/matchesTriggerItemQuery.js.map +1 -0
  26. package/dist/primitives/composer/trigger/triggerNavigationResource.d.ts.map +1 -1
  27. package/dist/primitives/composer/trigger/triggerNavigationResource.js +3 -5
  28. package/dist/primitives/composer/trigger/triggerNavigationResource.js.map +1 -1
  29. package/dist/primitives/messagePart/MessagePartText.d.ts +1 -1
  30. package/dist/primitives/thread/useThreadViewportAutoScroll.d.ts.map +1 -1
  31. package/dist/primitives/thread/useThreadViewportAutoScroll.js +3 -3
  32. package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
  33. package/dist/sandbox-host/SandboxHost.d.ts.map +1 -1
  34. package/dist/sandbox-host/SandboxHost.js +14 -5
  35. package/dist/sandbox-host/SandboxHost.js.map +1 -1
  36. package/dist/unstable/useMentionAdapter.d.ts +9 -3
  37. package/dist/unstable/useMentionAdapter.d.ts.map +1 -1
  38. package/dist/unstable/useMentionAdapter.js +50 -43
  39. package/dist/unstable/useMentionAdapter.js.map +1 -1
  40. package/dist/unstable/useModelContextSnapshot.d.ts +34 -0
  41. package/dist/unstable/useModelContextSnapshot.d.ts.map +1 -0
  42. package/dist/unstable/useModelContextSnapshot.js +61 -0
  43. package/dist/unstable/useModelContextSnapshot.js.map +1 -0
  44. package/dist/unstable/useSlashCommandAdapter.d.ts.map +1 -1
  45. package/dist/unstable/useSlashCommandAdapter.js +2 -8
  46. package/dist/unstable/useSlashCommandAdapter.js.map +1 -1
  47. package/dist/unstable/webmcp/WebMcpRegistrationResource.d.ts +14 -0
  48. package/dist/unstable/webmcp/WebMcpRegistrationResource.d.ts.map +1 -0
  49. package/dist/unstable/webmcp/WebMcpRegistrationResource.js +85 -0
  50. package/dist/unstable/webmcp/WebMcpRegistrationResource.js.map +1 -0
  51. package/dist/unstable/webmcp/convertTools.d.ts +21 -0
  52. package/dist/unstable/webmcp/convertTools.d.ts.map +1 -0
  53. package/dist/unstable/webmcp/convertTools.js +110 -0
  54. package/dist/unstable/webmcp/convertTools.js.map +1 -0
  55. package/dist/unstable/webmcp/useWebMcpProvider.d.ts +21 -0
  56. package/dist/unstable/webmcp/useWebMcpProvider.d.ts.map +1 -0
  57. package/dist/unstable/webmcp/useWebMcpProvider.js +105 -0
  58. package/dist/unstable/webmcp/useWebMcpProvider.js.map +1 -0
  59. package/dist/unstable/webmcp/webmcp-host.d.ts +39 -0
  60. package/dist/unstable/webmcp/webmcp-host.d.ts.map +1 -0
  61. package/dist/unstable/webmcp/webmcp-host.js +29 -0
  62. package/dist/unstable/webmcp/webmcp-host.js.map +1 -0
  63. package/dist/utils/Primitive.d.ts +4 -4
  64. package/package.json +12 -12
  65. package/src/index.ts +13 -0
  66. package/src/mcp-apps/McpAppRenderer.test.tsx +253 -5
  67. package/src/mcp-apps/McpAppRenderer.tsx +147 -52
  68. package/src/mcp-apps/app-frame.test.tsx +248 -1
  69. package/src/mcp-apps/app-frame.tsx +51 -5
  70. package/src/mcp-apps/bridge.ts +7 -2
  71. package/src/mcp-apps/index.ts +5 -1
  72. package/src/primitives/composer/ComposerInputPluginContext.tsx +0 -10
  73. package/src/primitives/composer/trigger/TriggerPopoverAction.tsx +4 -2
  74. package/src/primitives/composer/trigger/TriggerPopoverBehavior.test.tsx +109 -0
  75. package/src/primitives/composer/trigger/TriggerPopoverDirective.tsx +4 -2
  76. package/src/primitives/composer/trigger/matchesTriggerItemQuery.test.ts +52 -0
  77. package/src/primitives/composer/trigger/matchesTriggerItemQuery.ts +13 -0
  78. package/src/primitives/composer/trigger/triggerNavigationResource.ts +3 -10
  79. package/src/primitives/thread/useThreadViewportAutoScroll.ts +7 -6
  80. package/src/sandbox-host/SandboxHost.test.tsx +120 -2
  81. package/src/sandbox-host/SandboxHost.tsx +26 -6
  82. package/src/unstable/useComposerInputHistory.test.tsx +2 -1
  83. package/src/unstable/useMentionAdapter.test.tsx +337 -0
  84. package/src/unstable/useMentionAdapter.ts +94 -57
  85. package/src/unstable/useModelContextSnapshot.test.tsx +132 -0
  86. package/src/unstable/useModelContextSnapshot.ts +67 -0
  87. package/src/unstable/useSlashCommandAdapter.ts +2 -9
  88. package/src/unstable/webmcp/WebMcpRegistrationResource.ts +92 -0
  89. package/src/unstable/webmcp/__tests__/webmcp.fake.tsx +168 -0
  90. package/src/unstable/webmcp/convertTools.test.ts +393 -0
  91. package/src/unstable/webmcp/convertTools.ts +180 -0
  92. package/src/unstable/webmcp/useWebMcpProvider.test.tsx +466 -0
  93. package/src/unstable/webmcp/useWebMcpProvider.ts +150 -0
  94. package/src/unstable/webmcp/webmcp-host.test.ts +142 -0
  95. package/src/unstable/webmcp/webmcp-host.ts +83 -0
  96. package/dist/utils/invokeCallbackSafely.d.ts +0 -5
  97. package/dist/utils/invokeCallbackSafely.d.ts.map +0 -1
  98. package/dist/utils/invokeCallbackSafely.js +0 -15
  99. package/dist/utils/invokeCallbackSafely.js.map +0 -1
  100. package/dist/utils/useToolArgsFieldStatus.d.ts +0 -19
  101. package/dist/utils/useToolArgsFieldStatus.d.ts.map +0 -1
  102. package/dist/utils/useToolArgsFieldStatus.js +0 -23
  103. package/dist/utils/useToolArgsFieldStatus.js.map +0 -1
  104. package/src/utils/invokeCallbackSafely.ts +0 -11
  105. package/src/utils/useToolArgsFieldStatus.ts +0 -18
@@ -1,5 +1,5 @@
1
1
  import type { SandboxHostFrame } from "../sandbox-host/SandboxHost";
2
- import { invokeCallbackSafely } from "../utils/invokeCallbackSafely";
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
- invokeCallbackSafely(() => handlers.onError?.(error), "MCP App onError");
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) => {
@@ -1,4 +1,8 @@
1
- export { McpAppRenderer, type McpAppRendererOptions } from "./McpAppRenderer";
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
- onExecuteRef.current = onExecute;
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
- onInsertedRef.current = onInserted;
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 (matchesQuery(item, lower)) {
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) => matchesQuery(item, lower));
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 { isUserScrollUp } from "@assistant-ui/store/client";
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
- const viewportOverflows = div.scrollHeight > div.clientHeight + 1;
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 { invokeCallbackSafely } from "../utils/invokeCallbackSafely";
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
- const error = err instanceof Error ? err : new Error(String(err));
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", () => ({