@assistant-ui/react 0.15.15 → 0.15.16

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 (67) hide show
  1. package/dist/devtools/DevToolsHooks.d.ts +0 -2
  2. package/dist/devtools/DevToolsHooks.d.ts.map +1 -1
  3. package/dist/devtools/DevToolsHooks.js +10 -12
  4. package/dist/devtools/DevToolsHooks.js.map +1 -1
  5. package/dist/mcp-apps/McpAppRenderer.js +1 -1
  6. package/dist/mcp-apps/bridge.d.ts.map +1 -1
  7. package/dist/mcp-apps/bridge.js +2 -8
  8. package/dist/mcp-apps/bridge.js.map +1 -1
  9. package/dist/primitives/actionBar/ActionBarExportMarkdown.d.ts.map +1 -1
  10. package/dist/primitives/actionBar/ActionBarExportMarkdown.js +4 -1
  11. package/dist/primitives/actionBar/ActionBarExportMarkdown.js.map +1 -1
  12. package/dist/primitives/branchPicker/BranchPickerRoot.d.ts.map +1 -1
  13. package/dist/primitives/branchPicker/BranchPickerRoot.js +20 -15
  14. package/dist/primitives/branchPicker/BranchPickerRoot.js.map +1 -1
  15. package/dist/primitives/composer/ComposerDictationTranscript.d.ts +2 -2
  16. package/dist/primitives/composer/ComposerDictationTranscript.js +2 -2
  17. package/dist/primitives/composer/ComposerDictationTranscript.js.map +1 -1
  18. package/dist/primitives/composer/trigger/triggerSelectionResource.d.ts.map +1 -1
  19. package/dist/primitives/composer/trigger/triggerSelectionResource.js +24 -20
  20. package/dist/primitives/composer/trigger/triggerSelectionResource.js.map +1 -1
  21. package/dist/primitives/message.js +1 -1
  22. package/dist/primitives/thread/ThreadRoot.d.ts +3 -0
  23. package/dist/primitives/thread/ThreadRoot.d.ts.map +1 -1
  24. package/dist/primitives/thread/ThreadRoot.js +3 -0
  25. package/dist/primitives/thread/ThreadRoot.js.map +1 -1
  26. package/dist/sandbox-host/SandboxHost.d.ts.map +1 -1
  27. package/dist/sandbox-host/SandboxHost.js +4 -1
  28. package/dist/sandbox-host/SandboxHost.js.map +1 -1
  29. package/dist/unstable/useComposerInputHistory.js +1 -1
  30. package/dist/utils/invokeCallbackSafely.d.ts +5 -0
  31. package/dist/utils/invokeCallbackSafely.d.ts.map +1 -0
  32. package/dist/utils/invokeCallbackSafely.js +15 -0
  33. package/dist/utils/invokeCallbackSafely.js.map +1 -0
  34. package/dist/utils/smooth/SmoothContext.d.ts +6 -6
  35. package/dist/utils/smooth/useSmooth.d.ts +3 -0
  36. package/dist/utils/smooth/useSmooth.d.ts.map +1 -1
  37. package/dist/utils/smooth/useSmooth.js +16 -1
  38. package/dist/utils/smooth/useSmooth.js.map +1 -1
  39. package/package.json +5 -5
  40. package/src/devtools/DevToolsHooks.test.ts +40 -0
  41. package/src/devtools/DevToolsHooks.ts +10 -15
  42. package/src/mcp-apps/bridge.test.ts +21 -0
  43. package/src/mcp-apps/bridge.ts +2 -14
  44. package/src/model-context/frame/SPEC_AssistantFrame.md +1 -1
  45. package/src/primitives/actionBar/ActionBarExportMarkdown.test.tsx +67 -0
  46. package/src/primitives/actionBar/ActionBarExportMarkdown.tsx +3 -1
  47. package/src/primitives/branchPicker/BranchPickerRoot.tsx +5 -3
  48. package/src/primitives/composer/ComposerDictationTranscript.tsx +2 -2
  49. package/src/primitives/composer/trigger/triggerSelectionResource.test.ts +135 -0
  50. package/src/primitives/composer/trigger/triggerSelectionResource.ts +2 -0
  51. package/src/primitives/thread/ThreadRoot.tsx +3 -0
  52. package/src/sandbox-host/SandboxHost.test.tsx +61 -0
  53. package/src/sandbox-host/SandboxHost.tsx +6 -2
  54. package/src/unstable/useLiveCompletionAdapter.test.tsx +57 -0
  55. package/src/utils/invokeCallbackSafely.ts +11 -0
  56. package/src/utils/smooth/useSmooth.test.tsx +110 -0
  57. package/src/utils/smooth/useSmooth.ts +20 -6
  58. package/dist/context/react/utils/ensureBinding.d.ts +0 -5
  59. package/dist/context/react/utils/ensureBinding.d.ts.map +0 -1
  60. package/dist/context/react/utils/ensureBinding.js +0 -24
  61. package/dist/context/react/utils/ensureBinding.js.map +0 -1
  62. package/dist/context/react/utils/useRuntimeState.d.ts +0 -13
  63. package/dist/context/react/utils/useRuntimeState.d.ts.map +0 -1
  64. package/dist/context/react/utils/useRuntimeState.js +0 -33
  65. package/dist/context/react/utils/useRuntimeState.js.map +0 -1
  66. package/src/context/react/utils/ensureBinding.ts +0 -46
  67. package/src/context/react/utils/useRuntimeState.ts +0 -46
@@ -1,7 +1,7 @@
1
1
  import { __exportAll } from "../_virtual/_rolldown/runtime.js";
2
- import { MessagePrimitiveIf } from "./message/MessageIf.js";
3
2
  import { MessagePrimitiveRoot } from "./message/MessageRoot.js";
4
3
  import { MessagePrimitivePartByIndex as MessagePrimitivePartByIndexBase, MessagePrimitiveParts } from "./message/MessageParts.js";
4
+ import { MessagePrimitiveIf } from "./message/MessageIf.js";
5
5
  import { MessagePrimitiveAttachmentByIndex, MessagePrimitiveAttachments } from "./message/MessageAttachments.js";
6
6
  import { MessagePrimitiveError } from "./message/MessageError.js";
7
7
  import { MessagePrimitiveUnstable_PartsGrouped, MessagePrimitiveUnstable_PartsGroupedByParentId } from "./message/MessagePartsGrouped.js";
@@ -15,6 +15,9 @@ declare namespace ThreadPrimitiveRoot {
15
15
  * This component serves as the foundational wrapper for all thread-related components.
16
16
  * It provides the basic structure and context needed for thread functionality.
17
17
  *
18
+ * While this component is mounted, an unhandled Escape keydown stops active speech, even if
19
+ * the action bar that started it is no longer mounted.
20
+ *
18
21
  * @example
19
22
  * ```tsx
20
23
  * <ThreadPrimitive.Root>
@@ -1 +1 @@
1
- {"version":3,"file":"ThreadRoot.d.ts","names":[],"sources":["../../../src/primitives/thread/ThreadRoot.tsx"],"mappings":";;;kBAWiB;OACH,UAAU,oBAAoB,UAAU;;;;;OAKxC,QAAQ,gCAAgC,UAAU;;;;;;;;;;;;;;;;;;;cAoBnD,qCAAmB,0BAAA,KAAA,qBAAA,gBAAA,kCAAA,eAAA;;;;oBAiC9B,cAAA,0CAAA,cAAA"}
1
+ {"version":3,"file":"ThreadRoot.d.ts","names":[],"sources":["../../../src/primitives/thread/ThreadRoot.tsx"],"mappings":";;;kBAWiB;OACH,UAAU,oBAAoB,UAAU;;;;;OAKxC,QAAQ,gCAAgC,UAAU;;;;;;;;;;;;;;;;;;;;;;cAuBnD,qCAAmB,0BAAA,KAAA,qBAAA,gBAAA,kCAAA,eAAA;;;;oBAiC9B,cAAA,0CAAA,cAAA"}
@@ -11,6 +11,9 @@ import { jsx } from "react/jsx-runtime";
11
11
  * This component serves as the foundational wrapper for all thread-related components.
12
12
  * It provides the basic structure and context needed for thread functionality.
13
13
  *
14
+ * While this component is mounted, an unhandled Escape keydown stops active speech, even if
15
+ * the action bar that started it is no longer mounted.
16
+ *
14
17
  * @example
15
18
  * ```tsx
16
19
  * <ThreadPrimitive.Root>
@@ -1 +1 @@
1
- {"version":3,"file":"ThreadRoot.js","names":["c","_c","Primitive","ComponentRef","forwardRef","ComponentPropsWithoutRef","useEffect","useAui","ThreadPrimitiveRoot","Element","div","Props","props","ref","$","aui","t0","t1","handleKeyDown","event","key","defaultPrevented","thread","source","getState","speech","preventDefault","stopSpeaking","t2","error","Error","message","document","addEventListener","removeEventListener","displayName"],"sources":["../../../src/primitives/thread/ThreadRoot.tsx"],"sourcesContent":["\"use client\";\n\nimport { Primitive } from \"../../utils/Primitive\";\nimport {\n type ComponentRef,\n forwardRef,\n type ComponentPropsWithoutRef,\n useEffect,\n} from \"react\";\nimport { useAui } from \"@assistant-ui/store\";\n\nexport namespace ThreadPrimitiveRoot {\n export type Element = ComponentRef<typeof Primitive.div>;\n /**\n * Props for the ThreadPrimitive.Root component.\n * Accepts all standard div element props.\n */\n export type Props = ComponentPropsWithoutRef<typeof Primitive.div>;\n}\n\n/**\n * The root container component for a thread.\n *\n * This component serves as the foundational wrapper for all thread-related components.\n * It provides the basic structure and context needed for thread functionality.\n *\n * @example\n * ```tsx\n * <ThreadPrimitive.Root>\n * <ThreadPrimitive.Viewport>\n * <ThreadPrimitive.Messages>\n * {() => <MyMessage />}\n * </ThreadPrimitive.Messages>\n * </ThreadPrimitive.Viewport>\n * </ThreadPrimitive.Root>\n * ```\n */\nexport const ThreadPrimitiveRoot = forwardRef<\n ThreadPrimitiveRoot.Element,\n ThreadPrimitiveRoot.Props\n>((props, ref) => {\n const aui = useAui();\n\n useEffect(() => {\n const handleKeyDown = (event: KeyboardEvent) => {\n if (event.key !== \"Escape\") return;\n if (event.defaultPrevented || aui.thread.source === null) return;\n if (aui.thread.getState().speech == null) return;\n event.preventDefault();\n try {\n aui.thread.stopSpeaking();\n } catch (error) {\n // getState() is the last rendered snapshot, so speech can finish before this call.\n if (\n !(error instanceof Error) ||\n error.message !== \"No message is being spoken\"\n ) {\n throw error;\n }\n }\n };\n\n // Bubble phase lets capture-phase Escape handlers consume the event first.\n document.addEventListener(\"keydown\", handleKeyDown);\n return () => {\n document.removeEventListener(\"keydown\", handleKeyDown);\n };\n }, [aui]);\n\n return <Primitive.div {...props} ref={ref} />;\n});\n\nThreadPrimitiveRoot.displayName = \"ThreadPrimitive.Root\";\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;AAqCA,MAAaQ,sBAAsBJ,YAGjCQ,OAAAC,QAAA;CAAA,MAAAC,IAAAb,EAAA,CAAA;CACA,MAAAc,MAAYR,OAAO;CAAE,IAAAS;CAAA,IAAAC;CAAA,IAAAH,EAAA,OAAAC,KAAA;EAEXC,WAAA;GACR,MAAAE,iBAAsBC,UAAA;IACpB,IAAIA,MAAKC,QAAS,UAAQ;IAC1B,IAAID,MAAKE,oBAAqBN,IAAGO,OAAOC,WAAY,MAAI;IACxD,IAAIR,IAAGO,OAAOE,SAAU,CAAC,CAAAC,UAAW,MAAI;IACxCN,MAAKO,eAAgB;IACrB,IAAA;KACEX,IAAGO,OAAOK,aAAc;IAAC,SAAAC,IAAA;KAClBC,MAAAA,QAAAA;KAEP,IACE,EAAEA,iBAAiBC,UACnBD,MAAKE,YAAa,8BAElB,MAAMF;IACP;GACF;GAIHG,SAAQC,iBAAkB,WAAWf,aAAa;GAAC,aAC5C;IACLc,SAAQE,oBAAqB,WAAWhB,aAAa;GAAC;EACvD;EACAD,KAAA,CAACF,GAAG;EAACD,EAAA,KAAAC;EAAAD,EAAA,KAAAE;EAAAF,EAAA,KAAAG;CAAA,OAAA;EAAAD,KAAAF,EAAA;EAAAG,KAAAH,EAAA;CAAA;CAxBRR,UAAUU,IAwBPC,EAAK;CAAC,IAAAW;CAAA,IAAAd,EAAA,OAAAF,SAAAE,EAAA,OAAAD,KAAA;EAEFe,KAAA,oBAAA,UAAA,KAAA;GAAA,GAAmBhB;GAAYC;EAAG,CAAA;EAAIC,EAAA,KAAAF;EAAAE,EAAA,KAAAD;EAAAC,EAAA,KAAAc;CAAA,OAAAA,KAAAd,EAAA;CAAA,OAAtCc;AAAsC,CAC9C;AAEDpB,oBAAoB2B,cAAc"}
1
+ {"version":3,"file":"ThreadRoot.js","names":["c","_c","Primitive","ComponentRef","forwardRef","ComponentPropsWithoutRef","useEffect","useAui","ThreadPrimitiveRoot","Element","div","Props","props","ref","$","aui","t0","t1","handleKeyDown","event","key","defaultPrevented","thread","source","getState","speech","preventDefault","stopSpeaking","t2","error","Error","message","document","addEventListener","removeEventListener","displayName"],"sources":["../../../src/primitives/thread/ThreadRoot.tsx"],"sourcesContent":["\"use client\";\n\nimport { Primitive } from \"../../utils/Primitive\";\nimport {\n type ComponentRef,\n forwardRef,\n type ComponentPropsWithoutRef,\n useEffect,\n} from \"react\";\nimport { useAui } from \"@assistant-ui/store\";\n\nexport namespace ThreadPrimitiveRoot {\n export type Element = ComponentRef<typeof Primitive.div>;\n /**\n * Props for the ThreadPrimitive.Root component.\n * Accepts all standard div element props.\n */\n export type Props = ComponentPropsWithoutRef<typeof Primitive.div>;\n}\n\n/**\n * The root container component for a thread.\n *\n * This component serves as the foundational wrapper for all thread-related components.\n * It provides the basic structure and context needed for thread functionality.\n *\n * While this component is mounted, an unhandled Escape keydown stops active speech, even if\n * the action bar that started it is no longer mounted.\n *\n * @example\n * ```tsx\n * <ThreadPrimitive.Root>\n * <ThreadPrimitive.Viewport>\n * <ThreadPrimitive.Messages>\n * {() => <MyMessage />}\n * </ThreadPrimitive.Messages>\n * </ThreadPrimitive.Viewport>\n * </ThreadPrimitive.Root>\n * ```\n */\nexport const ThreadPrimitiveRoot = forwardRef<\n ThreadPrimitiveRoot.Element,\n ThreadPrimitiveRoot.Props\n>((props, ref) => {\n const aui = useAui();\n\n useEffect(() => {\n const handleKeyDown = (event: KeyboardEvent) => {\n if (event.key !== \"Escape\") return;\n if (event.defaultPrevented || aui.thread.source === null) return;\n if (aui.thread.getState().speech == null) return;\n event.preventDefault();\n try {\n aui.thread.stopSpeaking();\n } catch (error) {\n // getState() is the last rendered snapshot, so speech can finish before this call.\n if (\n !(error instanceof Error) ||\n error.message !== \"No message is being spoken\"\n ) {\n throw error;\n }\n }\n };\n\n // Bubble phase lets capture-phase Escape handlers consume the event first.\n document.addEventListener(\"keydown\", handleKeyDown);\n return () => {\n document.removeEventListener(\"keydown\", handleKeyDown);\n };\n }, [aui]);\n\n return <Primitive.div {...props} ref={ref} />;\n});\n\nThreadPrimitiveRoot.displayName = \"ThreadPrimitive.Root\";\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;AAwCA,MAAaQ,sBAAsBJ,YAGjCQ,OAAAC,QAAA;CAAA,MAAAC,IAAAb,EAAA,CAAA;CACA,MAAAc,MAAYR,OAAO;CAAE,IAAAS;CAAA,IAAAC;CAAA,IAAAH,EAAA,OAAAC,KAAA;EAEXC,WAAA;GACR,MAAAE,iBAAsBC,UAAA;IACpB,IAAIA,MAAKC,QAAS,UAAQ;IAC1B,IAAID,MAAKE,oBAAqBN,IAAGO,OAAOC,WAAY,MAAI;IACxD,IAAIR,IAAGO,OAAOE,SAAU,CAAC,CAAAC,UAAW,MAAI;IACxCN,MAAKO,eAAgB;IACrB,IAAA;KACEX,IAAGO,OAAOK,aAAc;IAAC,SAAAC,IAAA;KAClBC,MAAAA,QAAAA;KAEP,IACE,EAAEA,iBAAiBC,UACnBD,MAAKE,YAAa,8BAElB,MAAMF;IACP;GACF;GAIHG,SAAQC,iBAAkB,WAAWf,aAAa;GAAC,aAC5C;IACLc,SAAQE,oBAAqB,WAAWhB,aAAa;GAAC;EACvD;EACAD,KAAA,CAACF,GAAG;EAACD,EAAA,KAAAC;EAAAD,EAAA,KAAAE;EAAAF,EAAA,KAAAG;CAAA,OAAA;EAAAD,KAAAF,EAAA;EAAAG,KAAAH,EAAA;CAAA;CAxBRR,UAAUU,IAwBPC,EAAK;CAAC,IAAAW;CAAA,IAAAd,EAAA,OAAAF,SAAAE,EAAA,OAAAD,KAAA;EAEFe,KAAA,oBAAA,UAAA,KAAA;GAAA,GAAmBhB;GAAYC;EAAG,CAAA;EAAIC,EAAA,KAAAF;EAAAE,EAAA,KAAAD;EAAAC,EAAA,KAAAc;CAAA,OAAAA,KAAAd,EAAA;CAAA,OAAtCc;AAAsC,CAC9C;AAEDpB,oBAAoB2B,cAAc"}
@@ -1 +1 @@
1
- {"version":3,"file":"SandboxHost.d.ts","names":[],"sources":["../../src/sandbox-host/SandboxHost.tsx"],"mappings":";;;KAYY;EACV,UAAU;EACV;EACA;EACA;EACA;EACA;EACA,QAAQ;EACR;;KAGU,mBAAmB,KAC7B;KAIU;EACV,YAAY;;KAGF;EACV,YAAY,OAAO;EACnB;;KAGU;EAAmB;;KAEnB;EACV,SAAS;EACT;EACA,UAAU;EACV;EACA,eACE,OAAO,kBACP,MAAM,mBACH;EACL,YAAY,OAAO;EACnB,iBAAiB;;iBAGH,sBACd,OAAO,cACP;EAAS,QAAQ;EAAmB;;iBActB,cACd,SACA,YACA,SACA,WACA,cACA,SACA,kBACC,mCAAgB,IAAA"}
1
+ {"version":3,"file":"SandboxHost.d.ts","names":[],"sources":["../../src/sandbox-host/SandboxHost.tsx"],"mappings":";;;KAaY;EACV,UAAU;EACV;EACA;EACA;EACA;EACA;EACA,QAAQ;EACR;;KAGU,mBAAmB,KAC7B;KAIU;EACV,YAAY;;KAGF;EACV,YAAY,OAAO;EACnB;;KAGU;EAAmB;;KAEnB;EACV,SAAS;EACT;EACA,UAAU;EACV;EACA,eACE,OAAO,kBACP,MAAM,mBACH;EACL,YAAY,OAAO;EACnB,iBAAiB;;iBAGH,sBACd,OAAO,cACP;EAAS,QAAQ;EAAmB;;iBActB,cACd,SACA,YACA,SACA,WACA,cACA,SACA,kBACC,mCAAgB,IAAA"}
@@ -1,4 +1,5 @@
1
1
  "use client";
2
+ import { invokeCallbackSafely } from "../utils/invokeCallbackSafely.js";
2
3
  import { useEffect, useRef, useState } from "@assistant-ui/tap/react-shim";
3
4
  import { jsx } from "react/jsx-runtime";
4
5
  import { SafeContentFrame } from "safe-content-frame";
@@ -52,9 +53,11 @@ function SandboxHost({ content, contentKey, sandbox, maxHeight = DEFAULT_MAX_HEI
52
53
  };
53
54
  window.addEventListener("message", onMessage);
54
55
  }).catch((err) => {
56
+ if (cancelled) return;
55
57
  frame?.dispose();
56
58
  frame = null;
57
- liveRef.current.onError?.(err instanceof Error ? err : new Error(String(err)));
59
+ const error = err instanceof Error ? err : new Error(String(err));
60
+ invokeCallbackSafely(() => liveRef.current.onError?.(error), "SandboxHost onError");
58
61
  });
59
62
  return () => {
60
63
  cancelled = true;
@@ -1 +1 @@
1
- {"version":3,"file":"SandboxHost.js","names":["CSSProperties","useEffect","useRef","useState","RenderedFrame","SafeContentFrame","SandboxOption","DEFAULT_PRODUCT","DEFAULT_MAX_HEIGHT","SandboxHostConfig","sandbox","useShadowDom","enableBrowserCaching","salt","product","className","style","unsafeDocumentWrite","SandboxHostFrame","Pick","SandboxHostApi","setHeight","height","SandboxBridge","onMessage","event","MessageEvent","dispose","SandboxContent","html","SandboxHostProps","content","contentKey","maxHeight","createBridge","frame","host","onError","error","Error","containerProps","Record","isSandboxFrameMessage","iframe","HTMLIFrameElement","origin","source","contentWindow","LiveSnapshot","SandboxHost","containerRef","HTMLDivElement","contentHeight","setContentHeight","undefined","liveRef","current","container","cancelled","bridge","liveContent","sb","scf","renderOpts","renderHtml","then","rendered","hostApi","Number","isFinite","sendMessage","window","addEventListener","catch","err","String","removeEventListener","resolvedHeight","Math","min","mergedStyle"],"sources":["../../src/sandbox-host/SandboxHost.tsx"],"sourcesContent":["\"use client\";\n\nimport { type CSSProperties, useEffect, useRef, useState } from \"react\";\nimport {\n type RenderedFrame,\n SafeContentFrame,\n type SandboxOption,\n} from \"safe-content-frame\";\n\nconst DEFAULT_PRODUCT = \"assistant-ui-sandbox\";\nconst DEFAULT_MAX_HEIGHT = 800;\n\nexport type SandboxHostConfig = {\n sandbox?: SandboxOption[];\n useShadowDom?: boolean;\n enableBrowserCaching?: boolean;\n salt?: string;\n product?: string;\n className?: string;\n style?: CSSProperties;\n unsafeDocumentWrite?: boolean;\n};\n\nexport type SandboxHostFrame = Pick<\n RenderedFrame,\n \"iframe\" | \"origin\" | \"sendMessage\"\n>;\n\nexport type SandboxHostApi = {\n setHeight: (height: number) => void;\n};\n\nexport type SandboxBridge = {\n onMessage: (event: MessageEvent) => void;\n dispose: () => void;\n};\n\nexport type SandboxContent = { html: string };\n\nexport type SandboxHostProps = {\n content: SandboxContent;\n contentKey: string;\n sandbox?: SandboxHostConfig | undefined;\n maxHeight?: number | undefined;\n createBridge: (\n frame: SandboxHostFrame,\n host: SandboxHostApi,\n ) => SandboxBridge;\n onError?: ((error: Error) => void) | undefined;\n containerProps?: Record<string, string | undefined> | undefined;\n};\n\nexport function isSandboxFrameMessage(\n event: MessageEvent,\n frame: { iframe: HTMLIFrameElement; origin: string },\n): boolean {\n return (\n event.source === frame.iframe.contentWindow && event.origin === frame.origin\n );\n}\n\ntype LiveSnapshot = {\n content: SandboxContent;\n sandbox: SandboxHostConfig | undefined;\n createBridge: SandboxHostProps[\"createBridge\"];\n onError: SandboxHostProps[\"onError\"];\n};\n\nexport function SandboxHost({\n content,\n contentKey,\n sandbox,\n maxHeight = DEFAULT_MAX_HEIGHT,\n createBridge,\n onError,\n containerProps,\n}: SandboxHostProps) {\n const containerRef = useRef<HTMLDivElement>(null);\n const [contentHeight, setContentHeight] = useState<number | undefined>(\n undefined,\n );\n\n const liveRef = useRef<LiveSnapshot>(null!);\n liveRef.current = { content, sandbox, createBridge, onError };\n\n useEffect(() => {\n const container = containerRef.current;\n if (!container) return;\n\n let cancelled = false;\n let frame: RenderedFrame | null = null;\n let bridge: SandboxBridge | null = null;\n let onMessage: ((event: MessageEvent) => void) | null = null;\n\n const { content: liveContent, sandbox: sb } = liveRef.current;\n\n const scf = new SafeContentFrame(sb?.product ?? DEFAULT_PRODUCT, {\n ...(sb?.sandbox !== undefined && { sandbox: sb.sandbox }),\n ...(sb?.useShadowDom !== undefined && { useShadowDom: sb.useShadowDom }),\n ...(sb?.enableBrowserCaching !== undefined && {\n enableBrowserCaching: sb.enableBrowserCaching,\n }),\n ...(sb?.salt !== undefined && { salt: sb.salt }),\n });\n\n const renderOpts =\n sb?.unsafeDocumentWrite !== undefined\n ? { unsafeDocumentWrite: sb.unsafeDocumentWrite }\n : undefined;\n\n scf\n .renderHtml(liveContent.html, container, renderOpts)\n .then((rendered) => {\n if (cancelled) {\n rendered.dispose();\n return;\n }\n frame = rendered;\n\n const hostApi: SandboxHostApi = {\n setHeight: (height) => {\n if (\n typeof height === \"number\" &&\n Number.isFinite(height) &&\n height > 0\n ) {\n setContentHeight(height);\n }\n },\n };\n\n bridge = liveRef.current.createBridge(\n {\n iframe: rendered.iframe,\n origin: rendered.origin,\n sendMessage: rendered.sendMessage,\n },\n hostApi,\n );\n\n // Single owner of the window listener; the cross-origin guard runs\n // here so the bridge only sees frame-validated messages.\n onMessage = (event) => {\n if (!isSandboxFrameMessage(event, rendered)) return;\n bridge?.onMessage(event);\n };\n window.addEventListener(\"message\", onMessage);\n })\n .catch((err) => {\n frame?.dispose();\n frame = null;\n liveRef.current.onError?.(\n err instanceof Error ? err : new Error(String(err)),\n );\n });\n\n return () => {\n cancelled = true;\n if (onMessage) {\n window.removeEventListener(\"message\", onMessage);\n onMessage = null;\n }\n bridge?.dispose();\n bridge = null;\n frame?.dispose();\n frame = null;\n setContentHeight(undefined);\n };\n // oxlint-disable-next-line react/exhaustive-deps -- re-init only on contentKey change; live values flow through liveRef\n }, [contentKey]);\n\n const resolvedHeight =\n contentHeight != null ? Math.min(contentHeight, maxHeight) : undefined;\n const mergedStyle =\n resolvedHeight != null\n ? { ...sandbox?.style, height: resolvedHeight }\n : sandbox?.style;\n\n return (\n <div\n {...containerProps}\n ref={containerRef}\n className={sandbox?.className}\n style={mergedStyle}\n />\n );\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"SandboxHost.js","names":["CSSProperties","useEffect","useRef","useState","RenderedFrame","SafeContentFrame","SandboxOption","invokeCallbackSafely","DEFAULT_PRODUCT","DEFAULT_MAX_HEIGHT","SandboxHostConfig","sandbox","useShadowDom","enableBrowserCaching","salt","product","className","style","unsafeDocumentWrite","SandboxHostFrame","Pick","SandboxHostApi","setHeight","height","SandboxBridge","onMessage","event","MessageEvent","dispose","SandboxContent","html","SandboxHostProps","content","contentKey","maxHeight","createBridge","frame","host","onError","error","Error","containerProps","Record","isSandboxFrameMessage","iframe","HTMLIFrameElement","origin","source","contentWindow","LiveSnapshot","SandboxHost","containerRef","HTMLDivElement","contentHeight","setContentHeight","undefined","liveRef","current","container","cancelled","bridge","liveContent","sb","scf","renderOpts","renderHtml","then","rendered","hostApi","Number","isFinite","sendMessage","window","addEventListener","catch","err","String","removeEventListener","resolvedHeight","Math","min","mergedStyle"],"sources":["../../src/sandbox-host/SandboxHost.tsx"],"sourcesContent":["\"use client\";\n\nimport { type CSSProperties, useEffect, useRef, useState } from \"react\";\nimport {\n type RenderedFrame,\n SafeContentFrame,\n type SandboxOption,\n} from \"safe-content-frame\";\nimport { invokeCallbackSafely } from \"../utils/invokeCallbackSafely\";\n\nconst DEFAULT_PRODUCT = \"assistant-ui-sandbox\";\nconst DEFAULT_MAX_HEIGHT = 800;\n\nexport type SandboxHostConfig = {\n sandbox?: SandboxOption[];\n useShadowDom?: boolean;\n enableBrowserCaching?: boolean;\n salt?: string;\n product?: string;\n className?: string;\n style?: CSSProperties;\n unsafeDocumentWrite?: boolean;\n};\n\nexport type SandboxHostFrame = Pick<\n RenderedFrame,\n \"iframe\" | \"origin\" | \"sendMessage\"\n>;\n\nexport type SandboxHostApi = {\n setHeight: (height: number) => void;\n};\n\nexport type SandboxBridge = {\n onMessage: (event: MessageEvent) => void;\n dispose: () => void;\n};\n\nexport type SandboxContent = { html: string };\n\nexport type SandboxHostProps = {\n content: SandboxContent;\n contentKey: string;\n sandbox?: SandboxHostConfig | undefined;\n maxHeight?: number | undefined;\n createBridge: (\n frame: SandboxHostFrame,\n host: SandboxHostApi,\n ) => SandboxBridge;\n onError?: ((error: Error) => void) | undefined;\n containerProps?: Record<string, string | undefined> | undefined;\n};\n\nexport function isSandboxFrameMessage(\n event: MessageEvent,\n frame: { iframe: HTMLIFrameElement; origin: string },\n): boolean {\n return (\n event.source === frame.iframe.contentWindow && event.origin === frame.origin\n );\n}\n\ntype LiveSnapshot = {\n content: SandboxContent;\n sandbox: SandboxHostConfig | undefined;\n createBridge: SandboxHostProps[\"createBridge\"];\n onError: SandboxHostProps[\"onError\"];\n};\n\nexport function SandboxHost({\n content,\n contentKey,\n sandbox,\n maxHeight = DEFAULT_MAX_HEIGHT,\n createBridge,\n onError,\n containerProps,\n}: SandboxHostProps) {\n const containerRef = useRef<HTMLDivElement>(null);\n const [contentHeight, setContentHeight] = useState<number | undefined>(\n undefined,\n );\n\n const liveRef = useRef<LiveSnapshot>(null!);\n liveRef.current = { content, sandbox, createBridge, onError };\n\n useEffect(() => {\n const container = containerRef.current;\n if (!container) return;\n\n let cancelled = false;\n let frame: RenderedFrame | null = null;\n let bridge: SandboxBridge | null = null;\n let onMessage: ((event: MessageEvent) => void) | null = null;\n\n const { content: liveContent, sandbox: sb } = liveRef.current;\n\n const scf = new SafeContentFrame(sb?.product ?? DEFAULT_PRODUCT, {\n ...(sb?.sandbox !== undefined && { sandbox: sb.sandbox }),\n ...(sb?.useShadowDom !== undefined && { useShadowDom: sb.useShadowDom }),\n ...(sb?.enableBrowserCaching !== undefined && {\n enableBrowserCaching: sb.enableBrowserCaching,\n }),\n ...(sb?.salt !== undefined && { salt: sb.salt }),\n });\n\n const renderOpts =\n sb?.unsafeDocumentWrite !== undefined\n ? { unsafeDocumentWrite: sb.unsafeDocumentWrite }\n : undefined;\n\n scf\n .renderHtml(liveContent.html, container, renderOpts)\n .then((rendered) => {\n if (cancelled) {\n rendered.dispose();\n return;\n }\n frame = rendered;\n\n const hostApi: SandboxHostApi = {\n setHeight: (height) => {\n if (\n typeof height === \"number\" &&\n Number.isFinite(height) &&\n height > 0\n ) {\n setContentHeight(height);\n }\n },\n };\n\n bridge = liveRef.current.createBridge(\n {\n iframe: rendered.iframe,\n origin: rendered.origin,\n sendMessage: rendered.sendMessage,\n },\n hostApi,\n );\n\n // Single owner of the window listener; the cross-origin guard runs\n // here so the bridge only sees frame-validated messages.\n onMessage = (event) => {\n if (!isSandboxFrameMessage(event, rendered)) return;\n bridge?.onMessage(event);\n };\n window.addEventListener(\"message\", onMessage);\n })\n .catch((err) => {\n if (cancelled) return;\n frame?.dispose();\n frame = null;\n const error = err instanceof Error ? err : new Error(String(err));\n invokeCallbackSafely(\n () => liveRef.current.onError?.(error),\n \"SandboxHost onError\",\n );\n });\n\n return () => {\n cancelled = true;\n if (onMessage) {\n window.removeEventListener(\"message\", onMessage);\n onMessage = null;\n }\n bridge?.dispose();\n bridge = null;\n frame?.dispose();\n frame = null;\n setContentHeight(undefined);\n };\n // oxlint-disable-next-line react/exhaustive-deps -- re-init only on contentKey change; live values flow through liveRef\n }, [contentKey]);\n\n const resolvedHeight =\n contentHeight != null ? Math.min(contentHeight, maxHeight) : undefined;\n const mergedStyle =\n resolvedHeight != null\n ? { ...sandbox?.style, height: resolvedHeight }\n : sandbox?.style;\n\n return (\n <div\n {...containerProps}\n ref={containerRef}\n className={sandbox?.className}\n style={mergedStyle}\n />\n );\n}\n"],"mappings":"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"}
@@ -2,8 +2,8 @@
2
2
  import { useTriggerPopoverRootContextOptional } from "../primitives/composer/trigger/TriggerPopoverRootContext.js";
3
3
  import { useAui } from "@assistant-ui/store";
4
4
  import { useCallback, useEffect, useMemo, useRef } from "@assistant-ui/tap/react-shim";
5
- import { flushTapSync } from "@assistant-ui/tap";
6
5
  import { getThreadMessageText } from "@assistant-ui/core/internal";
6
+ import { flushTapSync } from "@assistant-ui/tap";
7
7
  //#region src/unstable/useComposerInputHistory.ts
8
8
  const deriveHistory = (messages) => {
9
9
  const entries = [];
@@ -0,0 +1,5 @@
1
+ //#region src/utils/invokeCallbackSafely.d.ts
2
+ declare const invokeCallbackSafely: (invoke: () => unknown, name: string) => void;
3
+ //#endregion
4
+ export { invokeCallbackSafely };
5
+ //# sourceMappingURL=invokeCallbackSafely.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"invokeCallbackSafely.d.ts","names":[],"sources":["../../src/utils/invokeCallbackSafely.ts"],"mappings":";cAAa,uBAAwB,uBAAuB"}
@@ -0,0 +1,15 @@
1
+ //#region src/utils/invokeCallbackSafely.ts
2
+ const invokeCallbackSafely = (invoke, name) => {
3
+ const reportFailure = (error) => {
4
+ console.error(`[assistant-ui] ${name} callback threw an error`, error);
5
+ };
6
+ try {
7
+ Promise.resolve(invoke()).catch(reportFailure);
8
+ } catch (error) {
9
+ reportFailure(error);
10
+ }
11
+ };
12
+ //#endregion
13
+ export { invokeCallbackSafely };
14
+
15
+ //# sourceMappingURL=invokeCallbackSafely.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"invokeCallbackSafely.js","names":["invokeCallbackSafely","invoke","name","reportFailure","error","console","Promise","resolve","catch"],"sources":["../../src/utils/invokeCallbackSafely.ts"],"sourcesContent":["export const invokeCallbackSafely = (invoke: () => unknown, name: string) => {\n const reportFailure = (error: unknown) => {\n console.error(`[assistant-ui] ${name} callback threw an error`, error);\n };\n\n try {\n void Promise.resolve(invoke()).catch(reportFailure);\n } catch (error) {\n reportFailure(error);\n }\n};\n"],"mappings":";AAAA,MAAaA,wBAAwBC,QAAuBC,SAAiB;CAC3E,MAAMC,iBAAiBC,UAAmB;EACxCC,QAAQD,MAAM,kBAAkBF,KAAI,2BAA4BE,KAAK;CACvE;CAEA,IAAI;EACF,QAAaG,QAAQN,OAAO,CAAC,CAAC,CAACO,MAAML,aAAa;CACpD,SAASC,OAAO;EACdD,cAAcC,KAAK;CACrB;AACF"}
@@ -14,7 +14,7 @@ declare const useSmoothStatus: {
14
14
  readonly error?: unknown;
15
15
  } | {
16
16
  readonly type: "requires-action";
17
- readonly reason: "interrupt";
17
+ readonly reason: "tool-calls" | "interrupt";
18
18
  };
19
19
  <TSelected>(selector: (state: {
20
20
  readonly type: "running";
@@ -26,7 +26,7 @@ declare const useSmoothStatus: {
26
26
  readonly error?: unknown;
27
27
  } | {
28
28
  readonly type: "requires-action";
29
- readonly reason: "interrupt";
29
+ readonly reason: "tool-calls" | "interrupt";
30
30
  }) => TSelected): TSelected;
31
31
  (options: {
32
32
  optional: true;
@@ -40,7 +40,7 @@ declare const useSmoothStatus: {
40
40
  readonly error?: unknown;
41
41
  } | {
42
42
  readonly type: "requires-action";
43
- readonly reason: "interrupt";
43
+ readonly reason: "tool-calls" | "interrupt";
44
44
  } | null;
45
45
  <TSelected>(options: {
46
46
  optional: true;
@@ -54,7 +54,7 @@ declare const useSmoothStatus: {
54
54
  readonly error?: unknown;
55
55
  } | {
56
56
  readonly type: "requires-action";
57
- readonly reason: "interrupt";
57
+ readonly reason: "tool-calls" | "interrupt";
58
58
  }) => TSelected;
59
59
  }): TSelected | null;
60
60
  }, useSmoothStatusStore: {
@@ -68,7 +68,7 @@ declare const useSmoothStatus: {
68
68
  readonly error?: unknown;
69
69
  } | {
70
70
  readonly type: "requires-action";
71
- readonly reason: "interrupt";
71
+ readonly reason: "tool-calls" | "interrupt";
72
72
  }>;
73
73
  (options: {
74
74
  optional: true;
@@ -82,7 +82,7 @@ declare const useSmoothStatus: {
82
82
  readonly error?: unknown;
83
83
  } | {
84
84
  readonly type: "requires-action";
85
- readonly reason: "interrupt";
85
+ readonly reason: "tool-calls" | "interrupt";
86
86
  }> | null;
87
87
  };
88
88
  //#endregion
@@ -37,6 +37,9 @@ type SmoothOptions = {
37
37
  * `true` uses the default rate, and a {@link SmoothOptions} object tunes
38
38
  * the reveal. Returns the part state with `text` replaced by the revealed
39
39
  * prefix and `status` reporting `running` until the reveal catches up.
40
+ * If the source settles before any character has been revealed, the text
41
+ * is committed immediately so a missed animation frame cannot leave an
42
+ * empty bubble.
40
43
  *
41
44
  * The reveal auto-disables under `prefers-reduced-motion: reduce`,
42
45
  * committing the full text immediately; this takes precedence over an
@@ -1 +1 @@
1
- {"version":3,"file":"useSmooth.d.ts","names":[],"sources":["../../../src/utils/smooth/useSmooth.ts"],"mappings":";;;;;KAkBY;;;;;;EAMV;;;;;;EAMA;;;;;EAKA;;;;;;;;EAQA;;;;;;;;;;;;;;;;;;;;;;cA6GW,YACX,OAAO,oBAAoB,kBAAkB,uBAC7C,mBAAkB,kBACjB,oBAAoB,kBAAkB"}
1
+ {"version":3,"file":"useSmooth.d.ts","names":[],"sources":["../../../src/utils/smooth/useSmooth.ts"],"mappings":";;;;;KAkBY;;;;;;EAMV;;;;;;EAMA;;;;;EAKA;;;;;;;;EAQA;;;;;;;;;;;;;;;;;;;;;;;;;cAgHW,YACX,OAAO,oBAAoB,kBAAkB,uBAC7C,mBAAkB,kBACjB,oBAAoB,kBAAkB"}
@@ -66,6 +66,9 @@ const positiveOr = (value, fallback) => value !== void 0 && value > 0 ? value :
66
66
  * `true` uses the default rate, and a {@link SmoothOptions} object tunes
67
67
  * the reveal. Returns the part state with `text` replaced by the revealed
68
68
  * prefix and `status` reporting `running` until the reveal catches up.
69
+ * If the source settles before any character has been revealed, the text
70
+ * is committed immediately so a missed animation frame cannot leave an
71
+ * empty bubble.
69
72
  *
70
73
  * The reveal auto-disables under `prefers-reduced-motion: reduce`,
71
74
  * committing the full text immediately; this takes precedence over an
@@ -147,17 +150,29 @@ const useSmooth = (state, smooth = false) => {
147
150
  animatorRef.currentText = text;
148
151
  animatorRef.targetText = text;
149
152
  animatorRef.stop();
153
+ setText(text);
150
154
  }
151
155
  return;
152
156
  }
153
157
  animatorRef.targetText = text;
158
+ if (state.status.type !== "running") {
159
+ if (animatorRef.currentText === "") {
160
+ animatorRef.currentText = text;
161
+ animatorRef.stop();
162
+ setText(text);
163
+ return;
164
+ }
165
+ animatorRef.start();
166
+ return;
167
+ }
154
168
  animatorRef.start();
155
169
  }, [
156
170
  animatorRef,
157
171
  enabled,
158
172
  text,
159
173
  state.status.type,
160
- part
174
+ part,
175
+ setText
161
176
  ]);
162
177
  useEffect(() => {
163
178
  return () => {
@@ -1 +1 @@
1
- {"version":3,"file":"useSmooth.js","names":["useEffect","useMemo","useRef","useState","useAui","useAuiState","MessagePartStatus","ReasoningMessagePart","TextMessagePart","MessagePartState","useCallbackRef","useMediaQuery","useSmoothStatusStore","writableStore","SmoothOptions","drainMs","maxCharIntervalMs","maxCharsPerFrame","minCommitMs","DEFAULT_DRAIN_MS","DEFAULT_MAX_CHAR_INTERVAL_MS","TextStreamAnimator","animationFrameId","lastUpdateTime","Date","now","lastCommitTime","targetText","Infinity","currentText","setText","newText","constructor","start","animate","stop","cancelAnimationFrame","currentTime","deltaTime","timeToConsume","remainingChars","length","baseTimePerChar","Math","min","frameLimit","charsToAdd","requestAnimationFrame","slice","isComplete","SMOOTH_STATUS","Object","freeze","type","positiveOr","value","fallback","undefined","useSmooth","state","smooth","text","reduceMotion","options","enabled","displayedText","setDisplayedText","status","aui","part","prevPart","setPrevPart","startsWith","smoothStatusStore","optional","target","setState","animatorRef","animatorPartRef","partChanged","current"],"sources":["../../../src/utils/smooth/useSmooth.ts"],"sourcesContent":["\"use client\";\n\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport { useAui, useAuiState } from \"@assistant-ui/store\";\nimport type {\n MessagePartStatus,\n ReasoningMessagePart,\n TextMessagePart,\n MessagePartState,\n} from \"@assistant-ui/core\";\nimport { useCallbackRef } from \"@radix-ui/react-use-callback-ref\";\nimport { useMediaQuery } from \"../hooks/useMediaQuery\";\nimport { useSmoothStatusStore } from \"./SmoothContext\";\nimport { writableStore } from \"../../context/ReadonlyStore\";\n\n/**\n * Tuning options for the smooth text streaming animation.\n */\nexport type SmoothOptions = {\n /**\n * Target time in milliseconds to drain the backlog of unrevealed\n * characters. Larger values reveal long backlogs more gradually.\n * @default 250\n */\n drainMs?: number | undefined;\n /**\n * Maximum time in milliseconds between revealed characters, i.e. the\n * slowest reveal rate when the backlog is short.\n * @default 5\n */\n maxCharIntervalMs?: number | undefined;\n /**\n * Maximum number of characters revealed per animation frame.\n * @default Infinity\n */\n maxCharsPerFrame?: number | undefined;\n /**\n * Minimum time in milliseconds between committed updates. The reveal keeps\n * advancing every frame, but the visible text (and the downstream re-render\n * and markdown re-parse it triggers) is committed at most once per interval.\n * The final frame always commits. `0` commits every frame.\n * @default 0\n */\n minCommitMs?: number | undefined;\n};\n\nconst DEFAULT_DRAIN_MS = 250;\nconst DEFAULT_MAX_CHAR_INTERVAL_MS = 5;\n\nclass TextStreamAnimator {\n private animationFrameId: number | null = null;\n private lastUpdateTime: number = Date.now();\n public lastCommitTime: number = 0;\n\n public targetText: string = \"\";\n public drainMs: number = DEFAULT_DRAIN_MS;\n public maxCharIntervalMs: number = DEFAULT_MAX_CHAR_INTERVAL_MS;\n public maxCharsPerFrame: number = Infinity;\n public minCommitMs: number = 0;\n\n public currentText: string;\n private setText: (newText: string) => void;\n\n constructor(currentText: string, setText: (newText: string) => void) {\n this.currentText = currentText;\n this.setText = setText;\n }\n\n start() {\n if (this.animationFrameId !== null) return;\n this.lastUpdateTime = Date.now();\n this.animate();\n }\n\n stop() {\n if (this.animationFrameId !== null) {\n cancelAnimationFrame(this.animationFrameId);\n this.animationFrameId = null;\n }\n }\n\n private animate = () => {\n const currentTime = Date.now();\n const deltaTime = currentTime - this.lastUpdateTime;\n let timeToConsume = deltaTime;\n\n const remainingChars = this.targetText.length - this.currentText.length;\n const baseTimePerChar = Math.min(\n this.maxCharIntervalMs,\n this.drainMs / remainingChars,\n );\n\n const frameLimit = Math.min(remainingChars, this.maxCharsPerFrame);\n let charsToAdd = 0;\n while (timeToConsume >= baseTimePerChar && charsToAdd < frameLimit) {\n charsToAdd++;\n timeToConsume -= baseTimePerChar;\n }\n // A cap-limited frame must not bank its surplus time, or the next\n // frame would burst past the cap.\n if (charsToAdd === frameLimit && frameLimit === this.maxCharsPerFrame) {\n timeToConsume = 0;\n }\n\n if (charsToAdd !== remainingChars) {\n this.animationFrameId = requestAnimationFrame(this.animate);\n } else {\n this.animationFrameId = null;\n }\n if (charsToAdd === 0) return;\n\n this.currentText = this.targetText.slice(\n 0,\n this.currentText.length + charsToAdd,\n );\n this.lastUpdateTime = currentTime - timeToConsume;\n\n const isComplete = charsToAdd === remainingChars;\n if (isComplete || currentTime - this.lastCommitTime >= this.minCommitMs) {\n this.lastCommitTime = currentTime;\n this.setText(this.currentText);\n }\n };\n}\n\nconst SMOOTH_STATUS: MessagePartStatus = Object.freeze({\n type: \"running\",\n});\n\nconst positiveOr = (value: number | undefined, fallback: number): number =>\n value !== undefined && value > 0 ? value : fallback;\n\n/**\n * Animates streamed message part text with a typewriter-style reveal.\n *\n * Takes the current part state and a `smooth` argument: `false` disables,\n * `true` uses the default rate, and a {@link SmoothOptions} object tunes\n * the reveal. Returns the part state with `text` replaced by the revealed\n * prefix and `status` reporting `running` until the reveal catches up.\n *\n * The reveal auto-disables under `prefers-reduced-motion: reduce`,\n * committing the full text immediately; this takes precedence over an\n * explicit `smooth={true}`.\n *\n * @example\n * ```tsx\n * const { text, status } = useSmooth(useMessagePartText(), {\n * drainMs: 500,\n * maxCharsPerFrame: 30,\n * });\n * ```\n */\nexport const useSmooth = (\n state: MessagePartState & (TextMessagePart | ReasoningMessagePart),\n smooth: boolean | SmoothOptions = false,\n): MessagePartState & (TextMessagePart | ReasoningMessagePart) => {\n const { text } = state;\n const reduceMotion = useMediaQuery(\"(prefers-reduced-motion: reduce)\");\n const options =\n typeof smooth === \"object\" && smooth !== null ? smooth : undefined;\n const enabled = smooth !== false && smooth !== null && !reduceMotion;\n const drainMs = positiveOr(options?.drainMs, DEFAULT_DRAIN_MS);\n const maxCharIntervalMs = positiveOr(\n options?.maxCharIntervalMs,\n DEFAULT_MAX_CHAR_INTERVAL_MS,\n );\n const maxCharsPerFrame = positiveOr(options?.maxCharsPerFrame, Infinity);\n const minCommitMs = positiveOr(options?.minCommitMs, 0);\n\n const [displayedText, setDisplayedText] = useState(\n state.status.type === \"running\" ? \"\" : text,\n );\n\n // Render-phase resync on part flip or text discontinuity, so the\n // first paint after a thread switch never shows the previous\n // part's text (#4051). `displayedText` is already a prefix of\n // `text` during normal streaming, so use it as the previous-text\n // reference instead of carrying separate state — avoids the\n // double render per streaming token. Read part identity through\n // `useAuiState` so we actually subscribe to its changes instead\n // of relying on a render-time proxy reference that may be stable\n // across thread swaps.\n const aui = useAui();\n const part = useAuiState(() => aui.part);\n const [prevPart, setPrevPart] = useState(part);\n if (part !== prevPart || !text.startsWith(displayedText)) {\n setPrevPart(part);\n setDisplayedText(state.status.type === \"running\" ? \"\" : text);\n }\n\n const smoothStatusStore = useSmoothStatusStore({ optional: true });\n const setText = useCallbackRef((text: string) => {\n setDisplayedText(text);\n if (smoothStatusStore) {\n const target =\n displayedText !== text || state.status.type === \"running\"\n ? SMOOTH_STATUS\n : state.status;\n writableStore(smoothStatusStore).setState(target, true);\n }\n });\n\n // TODO this is hacky\n useEffect(() => {\n if (smoothStatusStore) {\n const target =\n enabled && (displayedText !== text || state.status.type === \"running\")\n ? SMOOTH_STATUS\n : state.status;\n writableStore(smoothStatusStore).setState(target, true);\n }\n }, [smoothStatusStore, enabled, text, displayedText, state.status]);\n\n const [animatorRef] = useState<TextStreamAnimator>(\n new TextStreamAnimator(displayedText, setText),\n );\n\n useEffect(() => {\n animatorRef.drainMs = drainMs;\n animatorRef.maxCharIntervalMs = maxCharIntervalMs;\n animatorRef.maxCharsPerFrame = maxCharsPerFrame;\n animatorRef.minCommitMs = minCommitMs;\n }, [animatorRef, drainMs, maxCharIntervalMs, maxCharsPerFrame, minCommitMs]);\n\n const animatorPartRef = useRef(part);\n useEffect(() => {\n if (!enabled) {\n animatorRef.stop();\n return;\n }\n\n // Discontinuity: part flipped, or new text breaks continuation\n // of the animator's current target. Either case requires\n // resetting the cursor — without the part check, a new part\n // whose text happens to share a prefix with the previous target\n // would keep the stale cursor and flicker.\n const partChanged = animatorPartRef.current !== part;\n animatorPartRef.current = part;\n if (partChanged || !text.startsWith(animatorRef.targetText)) {\n if (state.status.type === \"running\") {\n animatorRef.currentText = \"\";\n animatorRef.targetText = text;\n animatorRef.lastCommitTime = 0;\n animatorRef.start();\n } else {\n animatorRef.currentText = text;\n animatorRef.targetText = text;\n animatorRef.stop();\n }\n return;\n }\n\n animatorRef.targetText = text;\n animatorRef.start();\n }, [animatorRef, enabled, text, state.status.type, part]);\n\n useEffect(() => {\n return () => {\n animatorRef.stop();\n };\n }, [animatorRef]);\n\n return useMemo(\n () =>\n enabled\n ? {\n ...state,\n text: displayedText,\n status: text === displayedText ? state.status : SMOOTH_STATUS,\n }\n : state,\n [enabled, displayedText, state, text],\n );\n};\n"],"mappings":"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"}
1
+ {"version":3,"file":"useSmooth.js","names":["useEffect","useMemo","useRef","useState","useAui","useAuiState","MessagePartStatus","ReasoningMessagePart","TextMessagePart","MessagePartState","useCallbackRef","useMediaQuery","useSmoothStatusStore","writableStore","SmoothOptions","drainMs","maxCharIntervalMs","maxCharsPerFrame","minCommitMs","DEFAULT_DRAIN_MS","DEFAULT_MAX_CHAR_INTERVAL_MS","TextStreamAnimator","animationFrameId","lastUpdateTime","Date","now","lastCommitTime","targetText","Infinity","currentText","setText","newText","constructor","start","animate","stop","cancelAnimationFrame","currentTime","deltaTime","timeToConsume","remainingChars","length","baseTimePerChar","Math","min","frameLimit","charsToAdd","requestAnimationFrame","slice","isComplete","SMOOTH_STATUS","Object","freeze","type","positiveOr","value","fallback","undefined","useSmooth","state","smooth","text","reduceMotion","options","enabled","displayedText","setDisplayedText","status","aui","part","prevPart","setPrevPart","startsWith","smoothStatusStore","optional","target","setState","animatorRef","animatorPartRef","partChanged","current"],"sources":["../../../src/utils/smooth/useSmooth.ts"],"sourcesContent":["\"use client\";\n\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport { useAui, useAuiState } from \"@assistant-ui/store\";\nimport type {\n MessagePartStatus,\n ReasoningMessagePart,\n TextMessagePart,\n MessagePartState,\n} from \"@assistant-ui/core\";\nimport { useCallbackRef } from \"@radix-ui/react-use-callback-ref\";\nimport { useMediaQuery } from \"../hooks/useMediaQuery\";\nimport { useSmoothStatusStore } from \"./SmoothContext\";\nimport { writableStore } from \"../../context/ReadonlyStore\";\n\n/**\n * Tuning options for the smooth text streaming animation.\n */\nexport type SmoothOptions = {\n /**\n * Target time in milliseconds to drain the backlog of unrevealed\n * characters. Larger values reveal long backlogs more gradually.\n * @default 250\n */\n drainMs?: number | undefined;\n /**\n * Maximum time in milliseconds between revealed characters, i.e. the\n * slowest reveal rate when the backlog is short.\n * @default 5\n */\n maxCharIntervalMs?: number | undefined;\n /**\n * Maximum number of characters revealed per animation frame.\n * @default Infinity\n */\n maxCharsPerFrame?: number | undefined;\n /**\n * Minimum time in milliseconds between committed updates. The reveal keeps\n * advancing every frame, but the visible text (and the downstream re-render\n * and markdown re-parse it triggers) is committed at most once per interval.\n * The final frame always commits. `0` commits every frame.\n * @default 0\n */\n minCommitMs?: number | undefined;\n};\n\nconst DEFAULT_DRAIN_MS = 250;\nconst DEFAULT_MAX_CHAR_INTERVAL_MS = 5;\n\nclass TextStreamAnimator {\n private animationFrameId: number | null = null;\n private lastUpdateTime: number = Date.now();\n public lastCommitTime: number = 0;\n\n public targetText: string = \"\";\n public drainMs: number = DEFAULT_DRAIN_MS;\n public maxCharIntervalMs: number = DEFAULT_MAX_CHAR_INTERVAL_MS;\n public maxCharsPerFrame: number = Infinity;\n public minCommitMs: number = 0;\n\n public currentText: string;\n private setText: (newText: string) => void;\n\n constructor(currentText: string, setText: (newText: string) => void) {\n this.currentText = currentText;\n this.setText = setText;\n }\n\n start() {\n if (this.animationFrameId !== null) return;\n this.lastUpdateTime = Date.now();\n this.animate();\n }\n\n stop() {\n if (this.animationFrameId !== null) {\n cancelAnimationFrame(this.animationFrameId);\n this.animationFrameId = null;\n }\n }\n\n private animate = () => {\n const currentTime = Date.now();\n const deltaTime = currentTime - this.lastUpdateTime;\n let timeToConsume = deltaTime;\n\n const remainingChars = this.targetText.length - this.currentText.length;\n const baseTimePerChar = Math.min(\n this.maxCharIntervalMs,\n this.drainMs / remainingChars,\n );\n\n const frameLimit = Math.min(remainingChars, this.maxCharsPerFrame);\n let charsToAdd = 0;\n while (timeToConsume >= baseTimePerChar && charsToAdd < frameLimit) {\n charsToAdd++;\n timeToConsume -= baseTimePerChar;\n }\n // A cap-limited frame must not bank its surplus time, or the next\n // frame would burst past the cap.\n if (charsToAdd === frameLimit && frameLimit === this.maxCharsPerFrame) {\n timeToConsume = 0;\n }\n\n if (charsToAdd !== remainingChars) {\n this.animationFrameId = requestAnimationFrame(this.animate);\n } else {\n this.animationFrameId = null;\n }\n if (charsToAdd === 0) return;\n\n this.currentText = this.targetText.slice(\n 0,\n this.currentText.length + charsToAdd,\n );\n this.lastUpdateTime = currentTime - timeToConsume;\n\n const isComplete = charsToAdd === remainingChars;\n if (isComplete || currentTime - this.lastCommitTime >= this.minCommitMs) {\n this.lastCommitTime = currentTime;\n this.setText(this.currentText);\n }\n };\n}\n\nconst SMOOTH_STATUS: MessagePartStatus = Object.freeze({\n type: \"running\",\n});\n\nconst positiveOr = (value: number | undefined, fallback: number): number =>\n value !== undefined && value > 0 ? value : fallback;\n\n/**\n * Animates streamed message part text with a typewriter-style reveal.\n *\n * Takes the current part state and a `smooth` argument: `false` disables,\n * `true` uses the default rate, and a {@link SmoothOptions} object tunes\n * the reveal. Returns the part state with `text` replaced by the revealed\n * prefix and `status` reporting `running` until the reveal catches up.\n * If the source settles before any character has been revealed, the text\n * is committed immediately so a missed animation frame cannot leave an\n * empty bubble.\n *\n * The reveal auto-disables under `prefers-reduced-motion: reduce`,\n * committing the full text immediately; this takes precedence over an\n * explicit `smooth={true}`.\n *\n * @example\n * ```tsx\n * const { text, status } = useSmooth(useMessagePartText(), {\n * drainMs: 500,\n * maxCharsPerFrame: 30,\n * });\n * ```\n */\nexport const useSmooth = (\n state: MessagePartState & (TextMessagePart | ReasoningMessagePart),\n smooth: boolean | SmoothOptions = false,\n): MessagePartState & (TextMessagePart | ReasoningMessagePart) => {\n const { text } = state;\n const reduceMotion = useMediaQuery(\"(prefers-reduced-motion: reduce)\");\n const options =\n typeof smooth === \"object\" && smooth !== null ? smooth : undefined;\n const enabled = smooth !== false && smooth !== null && !reduceMotion;\n const drainMs = positiveOr(options?.drainMs, DEFAULT_DRAIN_MS);\n const maxCharIntervalMs = positiveOr(\n options?.maxCharIntervalMs,\n DEFAULT_MAX_CHAR_INTERVAL_MS,\n );\n const maxCharsPerFrame = positiveOr(options?.maxCharsPerFrame, Infinity);\n const minCommitMs = positiveOr(options?.minCommitMs, 0);\n\n const [displayedText, setDisplayedText] = useState(\n state.status.type === \"running\" ? \"\" : text,\n );\n\n // Render-phase resync on part flip or text discontinuity, so the\n // first paint after a thread switch never shows the previous\n // part's text (#4051). `displayedText` is already a prefix of\n // `text` during normal streaming, so use it as the previous-text\n // reference instead of carrying separate state — avoids the\n // double render per streaming token. Read part identity through\n // `useAuiState` so we actually subscribe to its changes instead\n // of relying on a render-time proxy reference that may be stable\n // across thread swaps.\n const aui = useAui();\n const part = useAuiState(() => aui.part);\n const [prevPart, setPrevPart] = useState(part);\n if (part !== prevPart || !text.startsWith(displayedText)) {\n setPrevPart(part);\n setDisplayedText(state.status.type === \"running\" ? \"\" : text);\n }\n\n const smoothStatusStore = useSmoothStatusStore({ optional: true });\n const setText = useCallbackRef((text: string) => {\n setDisplayedText(text);\n if (smoothStatusStore) {\n const target =\n displayedText !== text || state.status.type === \"running\"\n ? SMOOTH_STATUS\n : state.status;\n writableStore(smoothStatusStore).setState(target, true);\n }\n });\n\n // TODO this is hacky\n useEffect(() => {\n if (smoothStatusStore) {\n const target =\n enabled && (displayedText !== text || state.status.type === \"running\")\n ? SMOOTH_STATUS\n : state.status;\n writableStore(smoothStatusStore).setState(target, true);\n }\n }, [smoothStatusStore, enabled, text, displayedText, state.status]);\n\n const [animatorRef] = useState<TextStreamAnimator>(\n new TextStreamAnimator(displayedText, setText),\n );\n\n useEffect(() => {\n animatorRef.drainMs = drainMs;\n animatorRef.maxCharIntervalMs = maxCharIntervalMs;\n animatorRef.maxCharsPerFrame = maxCharsPerFrame;\n animatorRef.minCommitMs = minCommitMs;\n }, [animatorRef, drainMs, maxCharIntervalMs, maxCharsPerFrame, minCommitMs]);\n\n const animatorPartRef = useRef(part);\n useEffect(() => {\n if (!enabled) {\n animatorRef.stop();\n return;\n }\n\n const partChanged = animatorPartRef.current !== part;\n animatorPartRef.current = part;\n // A new part whose text shares a prefix with the previous target would\n // keep the stale cursor and flicker without this reset.\n if (partChanged || !text.startsWith(animatorRef.targetText)) {\n if (state.status.type === \"running\") {\n animatorRef.currentText = \"\";\n animatorRef.targetText = text;\n animatorRef.lastCommitTime = 0;\n animatorRef.start();\n } else {\n animatorRef.currentText = text;\n animatorRef.targetText = text;\n animatorRef.stop();\n setText(text);\n }\n return;\n }\n\n animatorRef.targetText = text;\n if (state.status.type !== \"running\") {\n // No character has painted. A pending frame that never runs would\n // leave an empty bubble after settle.\n if (animatorRef.currentText === \"\") {\n animatorRef.currentText = text;\n animatorRef.stop();\n setText(text);\n return;\n }\n animatorRef.start();\n return;\n }\n\n animatorRef.start();\n }, [animatorRef, enabled, text, state.status.type, part, setText]);\n\n useEffect(() => {\n return () => {\n animatorRef.stop();\n };\n }, [animatorRef]);\n\n return useMemo(\n () =>\n enabled\n ? {\n ...state,\n text: displayedText,\n status: text === displayedText ? state.status : SMOOTH_STATUS,\n }\n : state,\n [enabled, displayedText, state, text],\n );\n};\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assistant-ui/react",
3
- "version": "0.15.15",
3
+ "version": "0.15.16",
4
4
  "description": "Open-source TypeScript/React library for building production-grade AI chat experiences",
5
5
  "keywords": [
6
6
  "ai",
@@ -55,9 +55,9 @@
55
55
  ],
56
56
  "sideEffects": false,
57
57
  "dependencies": {
58
- "@assistant-ui/core": "^0.3.14",
58
+ "@assistant-ui/core": "^0.3.15",
59
59
  "@assistant-ui/store": "^0.3.10",
60
- "@assistant-ui/tap": "^0.9.13",
60
+ "@assistant-ui/tap": "^0.9.14",
61
61
  "@radix-ui/primitive": "^1.1.7",
62
62
  "@radix-ui/react-collection": "^1.1.15",
63
63
  "@radix-ui/react-compose-refs": "^1.1.5",
@@ -67,12 +67,12 @@
67
67
  "@radix-ui/react-use-controllable-state": "^1.2.6",
68
68
  "@radix-ui/react-use-escape-keydown": "^1.1.5",
69
69
  "assistant-cloud": "^0.1.41",
70
- "assistant-stream": "^0.3.38",
70
+ "assistant-stream": "^0.3.39",
71
71
  "radix-ui": "^1.6.7",
72
72
  "react-textarea-autosize": "^8.5.9",
73
73
  "safe-content-frame": "^0.0.27",
74
74
  "zod": "^4.4.3",
75
- "zustand": "^5.0.14"
75
+ "zustand": "^5.0.15"
76
76
  },
77
77
  "peerDependencies": {
78
78
  "@types/react": "*",
@@ -0,0 +1,40 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { DevToolsHooks, DevToolsProviderApi } from "./DevToolsHooks";
3
+
4
+ describe("DevToolsHooks", () => {
5
+ afterEach(() => {
6
+ vi.restoreAllMocks();
7
+ });
8
+
9
+ it("notifies every listener when one throws", () => {
10
+ const error = new Error("listener failed");
11
+ const consoleError = vi
12
+ .spyOn(console, "error")
13
+ .mockImplementation(() => {});
14
+ const laterListener = vi.fn();
15
+ const unsubscribeThrowing = DevToolsHooks.subscribe(() => {
16
+ throw error;
17
+ });
18
+ const unsubscribeLater = DevToolsHooks.subscribe(laterListener);
19
+ let unregister = () => {};
20
+
21
+ try {
22
+ unregister = DevToolsProviderApi.register({});
23
+ const apiId = [...DevToolsHooks.getApis().keys()].at(-1);
24
+ expect(apiId).toBeDefined();
25
+
26
+ DevToolsHooks.clearEventLogs(apiId!);
27
+
28
+ expect(laterListener).toHaveBeenCalledTimes(2);
29
+ expect(consoleError).toHaveBeenCalledTimes(2);
30
+ expect(consoleError).toHaveBeenCalledWith(
31
+ "[assistant-ui] DevTools listener threw an error",
32
+ error,
33
+ );
34
+ } finally {
35
+ unsubscribeThrowing();
36
+ unsubscribeLater();
37
+ unregister();
38
+ }
39
+ });
40
+ });
@@ -1,4 +1,5 @@
1
1
  import type { Unsubscribe } from "@assistant-ui/core";
2
+ import { notifyEventListeners } from "@assistant-ui/core/internal";
2
3
  import type { AssistantClient } from "@assistant-ui/store";
3
4
 
4
5
  export interface EventLog {
@@ -54,6 +55,10 @@ const getHook = (): DevToolsHook => {
54
55
  return newHook;
55
56
  };
56
57
 
58
+ const notifyListeners = (apiId: number): void => {
59
+ notifyEventListeners(getHook().listeners, apiId, "DevTools");
60
+ };
61
+
57
62
  export class DevToolsHooks {
58
63
  static subscribe(listener: () => void): Unsubscribe {
59
64
  const hook = getHook();
@@ -69,17 +74,12 @@ export class DevToolsHooks {
69
74
  if (!entry) return;
70
75
 
71
76
  entry.logs = [];
72
- DevToolsHooks.notifyListeners(apiId);
77
+ notifyListeners(apiId);
73
78
  }
74
79
 
75
80
  static getApis(): Map<number, DevToolsApiEntry> {
76
81
  return getHook().apis;
77
82
  }
78
-
79
- private static notifyListeners(apiId: number): void {
80
- const hook = getHook();
81
- hook.listeners.forEach((listener) => listener(apiId));
82
- }
83
83
  }
84
84
 
85
85
  export class DevToolsProviderApi {
@@ -116,15 +116,15 @@ export class DevToolsProviderApi {
116
116
  );
117
117
  }
118
118
 
119
- DevToolsProviderApi.notifyListeners(apiId);
119
+ notifyListeners(apiId);
120
120
  });
121
121
 
122
122
  const stateUnsubscribe = aui.subscribe?.(() => {
123
- DevToolsProviderApi.notifyListeners(apiId);
123
+ notifyListeners(apiId);
124
124
  });
125
125
 
126
126
  hook.apis.set(apiId, entry);
127
- DevToolsProviderApi.notifyListeners(apiId);
127
+ notifyListeners(apiId);
128
128
 
129
129
  return () => {
130
130
  const hook = getHook();
@@ -136,12 +136,7 @@ export class DevToolsProviderApi {
136
136
 
137
137
  hook.apis.delete(apiId);
138
138
 
139
- DevToolsProviderApi.notifyListeners(apiId);
139
+ notifyListeners(apiId);
140
140
  };
141
141
  }
142
-
143
- private static notifyListeners(apiId: number): void {
144
- const hook = getHook();
145
- hook.listeners.forEach((listener) => listener(apiId));
146
- }
147
142
  }
@@ -560,4 +560,25 @@ describe("createMcpAppBridge", () => {
560
560
  consoleError.mockRestore();
561
561
  bridge.dispose();
562
562
  });
563
+
564
+ it("preserves the onError receiver", () => {
565
+ const { frame } = makeFrame();
566
+ const handlers = {
567
+ calls: 0,
568
+ onError() {
569
+ expect(this).toBe(handlers);
570
+ this.calls += 1;
571
+ },
572
+ };
573
+ const bridge = createMcpAppBridge({ frame, handlers });
574
+
575
+ deliver(bridge, {
576
+ jsonrpc: "2.0",
577
+ method: "notifications/error",
578
+ params: { message: "kaboom" },
579
+ });
580
+
581
+ expect(handlers.calls).toBe(1);
582
+ bridge.dispose();
583
+ });
563
584
  });
@@ -1,4 +1,5 @@
1
1
  import type { SandboxHostFrame } from "../sandbox-host/SandboxHost";
2
+ import { invokeCallbackSafely } from "../utils/invokeCallbackSafely";
2
3
  import {
3
4
  MCP_APP_PROTOCOL_VERSION,
4
5
  type McpAppBridgeHandlers,
@@ -123,21 +124,8 @@ export function createMcpAppBridge(
123
124
  });
124
125
  };
125
126
 
126
- const reportErrorCallbackFailure = (error: unknown) => {
127
- console.error(
128
- "[assistant-ui] MCP App onError callback threw an error",
129
- error,
130
- );
131
- };
132
-
133
127
  const reportError = (error: Error) => {
134
- try {
135
- void Promise.resolve(handlers.onError?.(error)).catch(
136
- reportErrorCallbackFailure,
137
- );
138
- } catch (callbackError) {
139
- reportErrorCallbackFailure(callbackError);
140
- }
128
+ invokeCallbackSafely(() => handlers.onError?.(error), "MCP App onError");
141
129
  };
142
130
 
143
131
  const handleRequest = async (req: McpAppJsonRpcRequest) => {
@@ -88,7 +88,7 @@ All messages are wrapped with a channel identifier to avoid conflicts with other
88
88
 
89
89
  #### Security Considerations
90
90
 
91
- 1. **Origin Validation**: Both sides can specify `targetOrigin` to restrict message sources
91
+ 1. **Origin Validation**: Both sides can specify `targetOrigin` to restrict message sources; providers share a fail-closed origin policy
92
92
  2. **Window Reference**: Host (parent) only accepts messages from the specific iframe window it's connected to
93
93
  3. **Message Channel**: Using a unique channel identifier prevents cross-talk with other postMessage users
94
94
 
@@ -0,0 +1,67 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+ import { act } from "react";
5
+ import { createRoot, type Root } from "react-dom/client";
6
+ import type * as AssistantStore from "@assistant-ui/store";
7
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
8
+ import { ActionBarPrimitiveExportMarkdown } from "./ActionBarExportMarkdown";
9
+
10
+ const { getCopyText } = vi.hoisted(() => ({
11
+ getCopyText: vi.fn(() => "# Exported message"),
12
+ }));
13
+
14
+ (globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
15
+
16
+ vi.mock("@assistant-ui/store", async (importOriginal) => {
17
+ const actual = await importOriginal<typeof AssistantStore>();
18
+ return {
19
+ ...actual,
20
+ useAui: () => ({ message: { getCopyText } }),
21
+ useAuiState: () => true,
22
+ };
23
+ });
24
+
25
+ describe("ActionBarPrimitiveExportMarkdown", () => {
26
+ let container: HTMLDivElement;
27
+ let root: Root;
28
+
29
+ beforeEach(() => {
30
+ getCopyText.mockClear();
31
+ container = document.createElement("div");
32
+ document.body.appendChild(container);
33
+ root = createRoot(container);
34
+ });
35
+
36
+ afterEach(async () => {
37
+ await act(async () => {
38
+ root.unmount();
39
+ });
40
+ container.remove();
41
+ vi.restoreAllMocks();
42
+ });
43
+
44
+ it("handles rejected asynchronous exports", async () => {
45
+ const error = new Error("remote save failed");
46
+ const onExport = vi.fn().mockRejectedValue(error);
47
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
48
+
49
+ await act(async () => {
50
+ root.render(
51
+ <ActionBarPrimitiveExportMarkdown onExport={onExport}>
52
+ Export
53
+ </ActionBarPrimitiveExportMarkdown>,
54
+ );
55
+ });
56
+
57
+ await act(async () => {
58
+ container.querySelector("button")!.click();
59
+ });
60
+
61
+ expect(onExport).toHaveBeenCalledWith("# Exported message");
62
+ expect(errorSpy).toHaveBeenCalledWith(
63
+ "[assistant-ui] markdown export failed:",
64
+ error,
65
+ );
66
+ });
67
+ });