@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.
- package/dist/devtools/DevToolsHooks.d.ts +0 -2
- package/dist/devtools/DevToolsHooks.d.ts.map +1 -1
- package/dist/devtools/DevToolsHooks.js +10 -12
- package/dist/devtools/DevToolsHooks.js.map +1 -1
- package/dist/mcp-apps/McpAppRenderer.js +1 -1
- package/dist/mcp-apps/bridge.d.ts.map +1 -1
- package/dist/mcp-apps/bridge.js +2 -8
- package/dist/mcp-apps/bridge.js.map +1 -1
- package/dist/primitives/actionBar/ActionBarExportMarkdown.d.ts.map +1 -1
- package/dist/primitives/actionBar/ActionBarExportMarkdown.js +4 -1
- package/dist/primitives/actionBar/ActionBarExportMarkdown.js.map +1 -1
- package/dist/primitives/branchPicker/BranchPickerRoot.d.ts.map +1 -1
- package/dist/primitives/branchPicker/BranchPickerRoot.js +20 -15
- package/dist/primitives/branchPicker/BranchPickerRoot.js.map +1 -1
- package/dist/primitives/composer/ComposerDictationTranscript.d.ts +2 -2
- package/dist/primitives/composer/ComposerDictationTranscript.js +2 -2
- package/dist/primitives/composer/ComposerDictationTranscript.js.map +1 -1
- package/dist/primitives/composer/trigger/triggerSelectionResource.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/triggerSelectionResource.js +24 -20
- package/dist/primitives/composer/trigger/triggerSelectionResource.js.map +1 -1
- package/dist/primitives/message.js +1 -1
- package/dist/primitives/thread/ThreadRoot.d.ts +3 -0
- package/dist/primitives/thread/ThreadRoot.d.ts.map +1 -1
- package/dist/primitives/thread/ThreadRoot.js +3 -0
- package/dist/primitives/thread/ThreadRoot.js.map +1 -1
- package/dist/sandbox-host/SandboxHost.d.ts.map +1 -1
- package/dist/sandbox-host/SandboxHost.js +4 -1
- package/dist/sandbox-host/SandboxHost.js.map +1 -1
- package/dist/unstable/useComposerInputHistory.js +1 -1
- package/dist/utils/invokeCallbackSafely.d.ts +5 -0
- package/dist/utils/invokeCallbackSafely.d.ts.map +1 -0
- package/dist/utils/invokeCallbackSafely.js +15 -0
- package/dist/utils/invokeCallbackSafely.js.map +1 -0
- package/dist/utils/smooth/SmoothContext.d.ts +6 -6
- package/dist/utils/smooth/useSmooth.d.ts +3 -0
- package/dist/utils/smooth/useSmooth.d.ts.map +1 -1
- package/dist/utils/smooth/useSmooth.js +16 -1
- package/dist/utils/smooth/useSmooth.js.map +1 -1
- package/package.json +5 -5
- package/src/devtools/DevToolsHooks.test.ts +40 -0
- package/src/devtools/DevToolsHooks.ts +10 -15
- package/src/mcp-apps/bridge.test.ts +21 -0
- package/src/mcp-apps/bridge.ts +2 -14
- package/src/model-context/frame/SPEC_AssistantFrame.md +1 -1
- package/src/primitives/actionBar/ActionBarExportMarkdown.test.tsx +67 -0
- package/src/primitives/actionBar/ActionBarExportMarkdown.tsx +3 -1
- package/src/primitives/branchPicker/BranchPickerRoot.tsx +5 -3
- package/src/primitives/composer/ComposerDictationTranscript.tsx +2 -2
- package/src/primitives/composer/trigger/triggerSelectionResource.test.ts +135 -0
- package/src/primitives/composer/trigger/triggerSelectionResource.ts +2 -0
- package/src/primitives/thread/ThreadRoot.tsx +3 -0
- package/src/sandbox-host/SandboxHost.test.tsx +61 -0
- package/src/sandbox-host/SandboxHost.tsx +6 -2
- package/src/unstable/useLiveCompletionAdapter.test.tsx +57 -0
- package/src/utils/invokeCallbackSafely.ts +11 -0
- package/src/utils/smooth/useSmooth.test.tsx +110 -0
- package/src/utils/smooth/useSmooth.ts +20 -6
- package/dist/context/react/utils/ensureBinding.d.ts +0 -5
- package/dist/context/react/utils/ensureBinding.d.ts.map +0 -1
- package/dist/context/react/utils/ensureBinding.js +0 -24
- package/dist/context/react/utils/ensureBinding.js.map +0 -1
- package/dist/context/react/utils/useRuntimeState.d.ts +0 -13
- package/dist/context/react/utils/useRuntimeState.d.ts.map +0 -1
- package/dist/context/react/utils/useRuntimeState.js +0 -33
- package/dist/context/react/utils/useRuntimeState.js.map +0 -1
- package/src/context/react/utils/ensureBinding.ts +0 -46
- 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
|
|
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":"
|
|
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":";;;
|
|
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
|
-
|
|
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
|
|
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 @@
|
|
|
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
|
|
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.
|
|
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.
|
|
58
|
+
"@assistant-ui/core": "^0.3.15",
|
|
59
59
|
"@assistant-ui/store": "^0.3.10",
|
|
60
|
-
"@assistant-ui/tap": "^0.9.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
119
|
+
notifyListeners(apiId);
|
|
120
120
|
});
|
|
121
121
|
|
|
122
122
|
const stateUnsubscribe = aui.subscribe?.(() => {
|
|
123
|
-
|
|
123
|
+
notifyListeners(apiId);
|
|
124
124
|
});
|
|
125
125
|
|
|
126
126
|
hook.apis.set(apiId, entry);
|
|
127
|
-
|
|
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
|
-
|
|
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
|
});
|
package/src/mcp-apps/bridge.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
});
|