@assistant-ui/react 0.15.26 → 0.15.27
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/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/mcp-apps/app-frame.js +2 -2
- package/dist/mcp-apps/app-frame.js.map +1 -1
- package/dist/mcp-apps/bridge.d.ts.map +1 -1
- package/dist/mcp-apps/bridge.js +28 -3
- package/dist/mcp-apps/bridge.js.map +1 -1
- package/dist/mcp-apps/index.d.ts +1 -1
- package/dist/mcp-apps/index.d.ts.map +1 -1
- package/dist/mcp-apps/types.d.ts +18 -1
- package/dist/mcp-apps/types.d.ts.map +1 -1
- package/dist/mcp-apps/types.js.map +1 -1
- package/dist/primitives/composer/ComposerAttachmentDropzone.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerAttachmentDropzone.js +14 -12
- package/dist/primitives/composer/ComposerAttachmentDropzone.js.map +1 -1
- package/dist/unstable/useComposerInput.d.ts +2 -0
- package/dist/unstable/useComposerInput.d.ts.map +1 -1
- package/dist/unstable/useComposerInput.js +2 -0
- package/dist/unstable/useComposerInput.js.map +1 -1
- package/package.json +4 -4
- package/src/index.ts +1 -0
- package/src/mcp-apps/McpAppRenderer.test.tsx +7 -2
- package/src/mcp-apps/app-frame.test.tsx +64 -6
- package/src/mcp-apps/app-frame.tsx +2 -2
- package/src/mcp-apps/bridge.test.ts +154 -0
- package/src/mcp-apps/bridge.ts +51 -9
- package/src/mcp-apps/index.ts +1 -0
- package/src/mcp-apps/types.ts +15 -1
- package/src/primitives/composer/ComposerAttachmentDropzone.test.tsx +32 -1
- package/src/primitives/composer/ComposerAttachmentDropzone.tsx +14 -12
- package/src/primitives/composer/ComposerDisabledThread.test.tsx +121 -0
- package/src/unstable/useComposerInput.ts +2 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ComposerAttachmentDropzone.js","names":["forwardRef","useCallback","useEffect","useState","ReactElement","isValidElement","composeEventHandlers","Slot","React","useAui","renderSlot","ComposerPrimitiveAttachmentDropzone","Element","HTMLDivElement","Props","HTMLAttributes","asChild","render","disabled","children","rest","ref","isDragging","setIsDragging","aui","handleDragEnterCapture","e","DragEvent","dataTransfer","types","includes","preventDefault","
|
|
1
|
+
{"version":3,"file":"ComposerAttachmentDropzone.js","names":["forwardRef","useCallback","useEffect","useState","ReactElement","isValidElement","composeEventHandlers","Slot","React","useAui","useAuiState","renderSlot","ComposerPrimitiveAttachmentDropzone","Element","HTMLDivElement","Props","HTMLAttributes","asChild","render","disabled","children","rest","ref","isDragging","setIsDragging","aui","disabledByThread","s","thread","isDisabled","handleDragEnterCapture","e","DragEvent","dataTransfer","types","includes","preventDefault","getState","capabilities","attachments","dropEffect","handleDragOverCapture","handleDragLeaveCapture","next","relatedTarget","Node","currentTarget","contains","handleDrop","files","Array","from","length","Promise","all","map","file","composer","addAttachment","mergedProps","onDragEnterCapture","onDragOverCapture","onDragLeaveCapture","onDropCapture","Comp","Root","displayName"],"sources":["../../../src/primitives/composer/ComposerAttachmentDropzone.tsx"],"sourcesContent":["\"use client\";\n\nimport {\n forwardRef,\n useCallback,\n useEffect,\n useState,\n type ReactElement,\n isValidElement,\n} from \"react\";\n\nimport { composeEventHandlers } from \"radix-ui/internal\";\nimport { Slot } from \"radix-ui\";\nimport type React from \"react\";\nimport { useAui, useAuiState } from \"@assistant-ui/store\";\nimport { renderSlot } from \"../../utils/Primitive\";\n\nexport namespace ComposerPrimitiveAttachmentDropzone {\n export type Element = HTMLDivElement;\n export type Props = React.HTMLAttributes<HTMLDivElement> & {\n asChild?: boolean | undefined;\n render?: ReactElement | undefined;\n disabled?: boolean | undefined;\n };\n}\n\nexport const ComposerPrimitiveAttachmentDropzone = forwardRef<\n HTMLDivElement,\n ComposerPrimitiveAttachmentDropzone.Props\n>(({ disabled, asChild = false, render, children, ...rest }, ref) => {\n const [isDragging, setIsDragging] = useState(false);\n const aui = useAui();\n const disabledByThread = useAuiState((s) => s.thread.isDisabled);\n const isDisabled = disabled || disabledByThread;\n\n useEffect(() => {\n if (!isDisabled) return;\n // Disabled handlers cannot clear the latch, so reset it when the enabled period ends.\n // eslint-disable-next-line react-hooks/set-state-in-effect\n setIsDragging(false);\n }, [isDisabled]);\n\n // An unprevented file drop navigates the tab to the file, so file drags are\n // claimed via preventDefault even when the runtime does not support attachments.\n const handleDragEnterCapture = useCallback(\n (e: React.DragEvent) => {\n if (!e.dataTransfer.types.includes(\"Files\")) return;\n e.preventDefault();\n if (isDisabled || !aui.thread.getState().capabilities.attachments) {\n e.dataTransfer.dropEffect = \"none\";\n return;\n }\n setIsDragging(true);\n },\n [isDisabled, aui],\n );\n\n const handleDragOverCapture = useCallback(\n (e: React.DragEvent) => {\n if (!e.dataTransfer.types.includes(\"Files\")) return;\n e.preventDefault();\n if (isDisabled || !aui.thread.getState().capabilities.attachments) {\n e.dataTransfer.dropEffect = \"none\";\n return;\n }\n if (!isDragging) setIsDragging(true);\n },\n [isDisabled, isDragging, aui],\n );\n\n const handleDragLeaveCapture = useCallback(\n (e: React.DragEvent) => {\n if (isDisabled) return;\n e.preventDefault();\n const next = e.relatedTarget as Node | null;\n if (next && e.currentTarget.contains(next)) {\n return;\n }\n setIsDragging(false);\n },\n [isDisabled],\n );\n\n const handleDrop = useCallback(\n async (e: React.DragEvent) => {\n setIsDragging(false);\n if (!e.dataTransfer.types.includes(\"Files\")) return;\n e.preventDefault();\n if (isDisabled) return;\n const files = Array.from(e.dataTransfer.files);\n if (!aui.thread.getState().capabilities.attachments || files.length === 0)\n return;\n await Promise.all(\n files.map(async (file) => {\n try {\n await aui.composer.addAttachment(file);\n } catch {\n // The composer runtime emits composer.attachmentAddError before rejecting;\n // the readonly and empty-thread stubs only throw.\n }\n }),\n );\n },\n [isDisabled, aui],\n );\n\n const mergedProps = {\n ...(isDragging && !isDisabled ? { \"data-dragging\": \"true\" } : null),\n ...rest,\n onDragEnterCapture: composeEventHandlers(\n rest.onDragEnterCapture,\n handleDragEnterCapture,\n ),\n onDragOverCapture: composeEventHandlers(\n rest.onDragOverCapture,\n handleDragOverCapture,\n ),\n onDragLeaveCapture: composeEventHandlers(\n rest.onDragLeaveCapture,\n handleDragLeaveCapture,\n ),\n onDropCapture: composeEventHandlers(rest.onDropCapture, handleDrop),\n ref,\n };\n\n if (render && isValidElement(render)) {\n return renderSlot(render, children, mergedProps);\n }\n\n const Comp = asChild ? Slot.Root : \"div\";\n return <Comp {...mergedProps}>{children}</Comp>;\n});\n\nComposerPrimitiveAttachmentDropzone.displayName =\n \"ComposerPrimitive.AttachmentDropzone\";\n"],"mappings":";;;;;;;;AA0BA,MAAaY,sCAAsCZ,YAGhD,EAAEmB,UAAUF,UAAU,OAAOC,QAAQE,UAAU,GAAGC,QAAQC,QAAQ;CACnE,MAAM,CAACC,YAAYC,iBAAiBrB,SAAS,KAAK;CAClD,MAAMsB,MAAMhB,OAAO;CACnB,MAAMiB,mBAAmBhB,aAAaiB,MAAMA,EAAEC,OAAOC,UAAU;CAC/D,MAAMA,aAAaV,YAAYO;CAE/BxB,gBAAgB;EACd,IAAI,CAAC2B,YAAY;EAGjBL,cAAc,KAAK;CACrB,GAAG,CAACK,UAAU,CAAC;CAIf,MAAMC,yBAAyB7B,aAC5B8B,MAAuB;EACtB,IAAI,CAACA,EAAEE,aAAaC,MAAMC,SAAS,OAAO,GAAG;EAC7CJ,EAAEK,eAAe;EACjB,IAAIP,cAAc,CAACJ,IAAIG,OAAOS,SAAS,CAAC,CAACC,aAAaC,aAAa;GACjER,EAAEE,aAAaO,aAAa;GAC5B;EACF;EACAhB,cAAc,IAAI;CACpB,GACA,CAACK,YAAYJ,GAAG,CAClB;CAEA,MAAMgB,wBAAwBxC,aAC3B8B,QAAuB;EACtB,IAAI,CAACA,IAAEE,aAAaC,MAAMC,SAAS,OAAO,GAAG;EAC7CJ,IAAEK,eAAe;EACjB,IAAIP,cAAc,CAACJ,IAAIG,OAAOS,SAAS,CAAC,CAACC,aAAaC,aAAa;GACjER,IAAEE,aAAaO,aAAa;GAC5B;EACF;EACA,IAAI,CAACjB,YAAYC,cAAc,IAAI;CACrC,GACA;EAACK;EAAYN;EAAYE;CAAG,CAC9B;CAEA,MAAMiB,yBAAyBzC,aAC5B8B,QAAuB;EACtB,IAAIF,YAAY;EAChBE,IAAEK,eAAe;EACjB,MAAMO,OAAOZ,IAAEa;EACf,IAAID,QAAQZ,IAAEe,cAAcC,SAASJ,IAAI,GACvC;EAEFnB,cAAc,KAAK;CACrB,GACA,CAACK,UAAU,CACb;CAEA,MAAMmB,aAAa/C,YACjB,OAAO8B,QAAuB;EAC5BP,cAAc,KAAK;EACnB,IAAI,CAACO,IAAEE,aAAaC,MAAMC,SAAS,OAAO,GAAG;EAC7CJ,IAAEK,eAAe;EACjB,IAAIP,YAAY;EAChB,MAAMoB,QAAQC,MAAMC,KAAKpB,IAAEE,aAAagB,KAAK;EAC7C,IAAI,CAACxB,IAAIG,OAAOS,SAAS,CAAC,CAACC,aAAaC,eAAeU,MAAMG,WAAW,GACtE;EACF,MAAMC,QAAQC,IACZL,MAAMM,IAAI,OAAOC,SAAS;GACxB,IAAI;IACF,MAAM/B,IAAIgC,SAASC,cAAcF,IAAI;GACvC,QAAQ,CAEN;EAEJ,CAAC,CACH;CACF,GACA,CAAC3B,YAAYJ,GAAG,CAClB;CAEA,MAAMkC,cAAc;EAClB,GAAIpC,cAAc,CAACM,aAAa,EAAE,iBAAiB,OAAO,IAAI;EAC9D,GAAGR;EACHuC,oBAAoBtD,qBAClBe,KAAKuC,oBACL9B,sBACF;EACA+B,mBAAmBvD,qBACjBe,KAAKwC,mBACLpB,qBACF;EACAqB,oBAAoBxD,qBAClBe,KAAKyC,oBACLpB,sBACF;EACAqB,eAAezD,qBAAqBe,KAAK0C,eAAef,UAAU;EAClE1B;CACF;CAEA,IAAIJ,UAAUb,eAAea,MAAM,GACjC,OAAOP,WAAWO,QAAQE,UAAUuC,WAAW;CAGjD,MAAMK,OAAO/C,UAAUV,KAAK0D,OAAO;CACnC,OAAO,oBAAC,MAAD;EAAM,GAAIN;EAAcvC;CAAe,CAAA;AAChD,CAAC;AAEDR,oCAAoCsD,cAClC"}
|
|
@@ -54,6 +54,8 @@ export type Unstable_ComposerInput = {
|
|
|
54
54
|
* disabled={isDisabled}
|
|
55
55
|
* onChange={(e) => setText(e.target.value)}
|
|
56
56
|
* onKeyDown={(e) => {
|
|
57
|
+
* if (e.nativeEvent.isComposing || e.keyCode === 229) return;
|
|
58
|
+
*
|
|
57
59
|
* if (e.key === "Enter" && !e.shiftKey && canSend) {
|
|
58
60
|
* e.preventDefault();
|
|
59
61
|
* send();
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useComposerInput.d.ts","sourceRoot":"","sources":["../../src/unstable/useComposerInput.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAC;AACpE,OAAO,EACL,KAAK,uBAAuB,EAI7B,wDAAqD;AAEtD,+GAA+G;AAC/G,MAAM,MAAM,gCAAgC,GAAG;IAC7C;;;;OAIG;IACH,QAAQ,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;CAChC,CAAC;AAEF,+GAA+G;AAC/G,MAAM,MAAM,sBAAsB,GAAG;IACnC,uEAAuE;IACvE,KAAK,EAAE,MAAM,CAAC;IACd;;;;OAIG;IACH,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B;;;OAGG;IACH,IAAI,CAAC,OAAO,CAAC,EAAE,mBAAmB,GAAG,IAAI,CAAC;IAC1C;;;OAGG;IACH,UAAU,EAAE,OAAO,CAAC;IACpB;;;;OAIG;IACH,OAAO,EAAE,OAAO,CAAC;CAClB,CAAC;AAEF
|
|
1
|
+
{"version":3,"file":"useComposerInput.d.ts","sourceRoot":"","sources":["../../src/unstable/useComposerInput.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAC;AACpE,OAAO,EACL,KAAK,uBAAuB,EAI7B,wDAAqD;AAEtD,+GAA+G;AAC/G,MAAM,MAAM,gCAAgC,GAAG;IAC7C;;;;OAIG;IACH,QAAQ,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;CAChC,CAAC;AAEF,+GAA+G;AAC/G,MAAM,MAAM,sBAAsB,GAAG;IACnC,uEAAuE;IACvE,KAAK,EAAE,MAAM,CAAC;IACd;;;;OAIG;IACH,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B;;;OAGG;IACH,IAAI,CAAC,OAAO,CAAC,EAAE,mBAAmB,GAAG,IAAI,CAAC;IAC1C;;;OAGG;IACH,UAAU,EAAE,OAAO,CAAC;IACpB;;;;OAIG;IACH,OAAO,EAAE,OAAO,CAAC;CAClB,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,yBAAyB,CACvC,OAAO,CAAC,EAAE,gCAAgC,GACzC,sBAAsB,CA0BxB;AAED,+GAA+G;AAC/G,MAAM,MAAM,gCAAgC,GAAG,uBAAuB,CAAC;AAEvE;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,mCAAmC,IAAI,gCAAgC,CAEtF"}
|
|
@@ -23,6 +23,8 @@ import { flushTapSync } from "@assistant-ui/tap";
|
|
|
23
23
|
* disabled={isDisabled}
|
|
24
24
|
* onChange={(e) => setText(e.target.value)}
|
|
25
25
|
* onKeyDown={(e) => {
|
|
26
|
+
* if (e.nativeEvent.isComposing || e.keyCode === 229) return;
|
|
27
|
+
*
|
|
26
28
|
* if (e.key === "Enter" && !e.shiftKey && canSend) {
|
|
27
29
|
* e.preventDefault();
|
|
28
30
|
* send();
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useComposerInput.js","names":["useCallback","useAui","flushTapSync","useComposerSend","ComposerSendOptions","TriggerPopoverAriaProps","useComposerInputDisabled","useComposerInputValue","useTriggerPopoverAriaProps","Unstable_UseComposerInputOptions","disabled","Unstable_ComposerInput","value","setText","text","send","options","isDisabled","canSend","unstable_useComposerInput","aui","composer","getState","isEditing","rawSend","sendDisabled","sendOptions","Unstable_TriggerPopoverAriaProps","unstable_useTriggerPopoverAriaProps"],"sources":["../../src/unstable/useComposerInput.ts"],"sourcesContent":["\"use client\";\n\nimport { useCallback } from \"react\";\nimport { useAui } from \"@assistant-ui/store\";\nimport { flushTapSync } from \"@assistant-ui/tap\";\nimport { useComposerSend } from \"@assistant-ui/core/react\";\nimport type { ComposerSendOptions } from \"@assistant-ui/core/store\";\nimport {\n type TriggerPopoverAriaProps,\n useComposerInputDisabled,\n useComposerInputValue,\n useTriggerPopoverAriaProps,\n} from \"../primitives/composer/useComposerInputState\";\n\n/** @deprecated Experimental since 2026-06-23. Not scheduled for removal; the API may change in any release. */\nexport type Unstable_UseComposerInputOptions = {\n /**\n * Disables the input in addition to the composer's own disabled sources\n * (thread disabled, active dictation). When disabled, `isDisabled` is `true`\n * and `canSend` is `false`.\n */\n disabled?: boolean | undefined;\n};\n\n/** @deprecated Experimental since 2026-06-23. Not scheduled for removal; the API may change in any release. */\nexport type Unstable_ComposerInput = {\n /** Current composer text, or `\"\"` when the composer is not editing. */\n value: string;\n /**\n * Writes `text` into the composer, mirroring `ComposerPrimitive.Input`:\n * a no-op unless the composer is editing; the controlled value stays in\n * sync within the same tick.\n */\n setText(text: string): void;\n /**\n * Sends the current message when `canSend` is `true`; otherwise a no-op.\n * Accepts the same options as the composer send action.\n */\n send(options?: ComposerSendOptions): void;\n /**\n * Whether the input is disabled, combining the `disabled` option with the\n * composer's own disabled sources (thread disabled, active dictation).\n */\n isDisabled: boolean;\n /**\n * Whether a send is currently available. Matches `ComposerPrimitive.Send`\n * gating (non-empty editing composer, not running without queue support) and\n * is additionally `false` while `isDisabled`.\n */\n canSend: boolean;\n};\n\n/**\n * Headless bridge to the composer's text value and send action, for building a\n * custom composer input without `ComposerPrimitive.Input`. It is a thin bridge,\n * not a second input: it does not own keyboard behavior, autosize, IME or\n * contentEditable sync, paste/drop attachments, focus management, or rich-text\n * state. Spread `unstable_useTriggerPopoverAriaProps()` onto your element for\n * trigger-popover combobox semantics.\n *\n * @deprecated Experimental since 2026-06-23. Not scheduled for removal; the API may change in any release.\n *\n * @example\n * ```tsx\n * const { value, setText, send, isDisabled, canSend } = unstable_useComposerInput();\n * <textarea\n * value={value}\n * disabled={isDisabled}\n * onChange={(e) => setText(e.target.value)}\n * onKeyDown={(e) => {\n * if (e.key === \"Enter\" && !e.shiftKey && canSend) {\n * e.preventDefault();\n * send();\n * }\n * }}\n * />\n * ```\n */\nexport function unstable_useComposerInput(\n options?: Unstable_UseComposerInputOptions,\n): Unstable_ComposerInput {\n const aui = useAui();\n const value = useComposerInputValue();\n const isDisabled = useComposerInputDisabled(options?.disabled);\n\n const setText = useCallback(\n (text: string) => {\n if (!aui.composer.getState().isEditing) return;\n flushTapSync(() => {\n aui.composer.setText(text);\n });\n },\n [aui],\n );\n\n const { send: rawSend, disabled: sendDisabled } = useComposerSend();\n const canSend = !sendDisabled && !isDisabled;\n const send = useCallback(\n (sendOptions?: ComposerSendOptions) => {\n if (!canSend) return;\n rawSend(sendOptions);\n },\n [canSend, rawSend],\n );\n\n return { value, setText, send, isDisabled, canSend };\n}\n\n/** @deprecated Experimental since 2026-06-23. Not scheduled for removal; the API may change in any release. */\nexport type Unstable_TriggerPopoverAriaProps = TriggerPopoverAriaProps;\n\n/**\n * ARIA combobox attributes for the focused element (typically the composer\n * input) describing the open trigger popover, per the WAI-ARIA editable\n * combobox pattern. Returns an empty object outside a `TriggerPopoverRoot` or\n * when no popover is open. Spread these last so they take precedence over any\n * matching ARIA props you set yourself, mirroring `ComposerPrimitive.Input`.\n *\n * @deprecated Experimental since 2026-06-23. Not scheduled for removal; the API may change in any release.\n *\n * @example\n * ```tsx\n * const aria = unstable_useTriggerPopoverAriaProps();\n * <textarea {...aria} />\n * ```\n */\nexport function unstable_useTriggerPopoverAriaProps(): Unstable_TriggerPopoverAriaProps {\n return useTriggerPopoverAriaProps();\n}\n"],"mappings":"
|
|
1
|
+
{"version":3,"file":"useComposerInput.js","names":["useCallback","useAui","flushTapSync","useComposerSend","ComposerSendOptions","TriggerPopoverAriaProps","useComposerInputDisabled","useComposerInputValue","useTriggerPopoverAriaProps","Unstable_UseComposerInputOptions","disabled","Unstable_ComposerInput","value","setText","text","send","options","isDisabled","canSend","unstable_useComposerInput","aui","composer","getState","isEditing","rawSend","sendDisabled","sendOptions","Unstable_TriggerPopoverAriaProps","unstable_useTriggerPopoverAriaProps"],"sources":["../../src/unstable/useComposerInput.ts"],"sourcesContent":["\"use client\";\n\nimport { useCallback } from \"react\";\nimport { useAui } from \"@assistant-ui/store\";\nimport { flushTapSync } from \"@assistant-ui/tap\";\nimport { useComposerSend } from \"@assistant-ui/core/react\";\nimport type { ComposerSendOptions } from \"@assistant-ui/core/store\";\nimport {\n type TriggerPopoverAriaProps,\n useComposerInputDisabled,\n useComposerInputValue,\n useTriggerPopoverAriaProps,\n} from \"../primitives/composer/useComposerInputState\";\n\n/** @deprecated Experimental since 2026-06-23. Not scheduled for removal; the API may change in any release. */\nexport type Unstable_UseComposerInputOptions = {\n /**\n * Disables the input in addition to the composer's own disabled sources\n * (thread disabled, active dictation). When disabled, `isDisabled` is `true`\n * and `canSend` is `false`.\n */\n disabled?: boolean | undefined;\n};\n\n/** @deprecated Experimental since 2026-06-23. Not scheduled for removal; the API may change in any release. */\nexport type Unstable_ComposerInput = {\n /** Current composer text, or `\"\"` when the composer is not editing. */\n value: string;\n /**\n * Writes `text` into the composer, mirroring `ComposerPrimitive.Input`:\n * a no-op unless the composer is editing; the controlled value stays in\n * sync within the same tick.\n */\n setText(text: string): void;\n /**\n * Sends the current message when `canSend` is `true`; otherwise a no-op.\n * Accepts the same options as the composer send action.\n */\n send(options?: ComposerSendOptions): void;\n /**\n * Whether the input is disabled, combining the `disabled` option with the\n * composer's own disabled sources (thread disabled, active dictation).\n */\n isDisabled: boolean;\n /**\n * Whether a send is currently available. Matches `ComposerPrimitive.Send`\n * gating (non-empty editing composer, not running without queue support) and\n * is additionally `false` while `isDisabled`.\n */\n canSend: boolean;\n};\n\n/**\n * Headless bridge to the composer's text value and send action, for building a\n * custom composer input without `ComposerPrimitive.Input`. It is a thin bridge,\n * not a second input: it does not own keyboard behavior, autosize, IME or\n * contentEditable sync, paste/drop attachments, focus management, or rich-text\n * state. Spread `unstable_useTriggerPopoverAriaProps()` onto your element for\n * trigger-popover combobox semantics.\n *\n * @deprecated Experimental since 2026-06-23. Not scheduled for removal; the API may change in any release.\n *\n * @example\n * ```tsx\n * const { value, setText, send, isDisabled, canSend } = unstable_useComposerInput();\n * <textarea\n * value={value}\n * disabled={isDisabled}\n * onChange={(e) => setText(e.target.value)}\n * onKeyDown={(e) => {\n * if (e.nativeEvent.isComposing || e.keyCode === 229) return;\n *\n * if (e.key === \"Enter\" && !e.shiftKey && canSend) {\n * e.preventDefault();\n * send();\n * }\n * }}\n * />\n * ```\n */\nexport function unstable_useComposerInput(\n options?: Unstable_UseComposerInputOptions,\n): Unstable_ComposerInput {\n const aui = useAui();\n const value = useComposerInputValue();\n const isDisabled = useComposerInputDisabled(options?.disabled);\n\n const setText = useCallback(\n (text: string) => {\n if (!aui.composer.getState().isEditing) return;\n flushTapSync(() => {\n aui.composer.setText(text);\n });\n },\n [aui],\n );\n\n const { send: rawSend, disabled: sendDisabled } = useComposerSend();\n const canSend = !sendDisabled && !isDisabled;\n const send = useCallback(\n (sendOptions?: ComposerSendOptions) => {\n if (!canSend) return;\n rawSend(sendOptions);\n },\n [canSend, rawSend],\n );\n\n return { value, setText, send, isDisabled, canSend };\n}\n\n/** @deprecated Experimental since 2026-06-23. Not scheduled for removal; the API may change in any release. */\nexport type Unstable_TriggerPopoverAriaProps = TriggerPopoverAriaProps;\n\n/**\n * ARIA combobox attributes for the focused element (typically the composer\n * input) describing the open trigger popover, per the WAI-ARIA editable\n * combobox pattern. Returns an empty object outside a `TriggerPopoverRoot` or\n * when no popover is open. Spread these last so they take precedence over any\n * matching ARIA props you set yourself, mirroring `ComposerPrimitive.Input`.\n *\n * @deprecated Experimental since 2026-06-23. Not scheduled for removal; the API may change in any release.\n *\n * @example\n * ```tsx\n * const aria = unstable_useTriggerPopoverAriaProps();\n * <textarea {...aria} />\n * ```\n */\nexport function unstable_useTriggerPopoverAriaProps(): Unstable_TriggerPopoverAriaProps {\n return useTriggerPopoverAriaProps();\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgFA,SAAgBmB,0BACdH,SACwB;CACxB,MAAMI,MAAMnB,OAAO;CACnB,MAAMW,QAAQL,sBAAsB;CACpC,MAAMU,aAAaX,yBAAyBU,SAASN,QAAQ;CAE7D,MAAMG,UAAUb,aACbc,SAAiB;EAChB,IAAI,CAACM,IAAIC,SAASC,SAAS,CAAC,CAACC,WAAW;EACxCrB,mBAAmB;GACjBkB,IAAIC,SAASR,QAAQC,IAAI;EAC3B,CAAC;CACH,GACA,CAACM,GAAG,CACN;CAEA,MAAM,EAAEL,MAAMS,SAASd,UAAUe,iBAAiBtB,gBAAgB;CAClE,MAAMe,UAAU,CAACO,gBAAgB,CAACR;CASlC,OAAO;EAAEL;EAAOC;EAASE,MARZf,aACV0B,gBAAsC;GACrC,IAAI,CAACR,SAAS;GACdM,QAAQE,WAAW;EACrB,GACA,CAACR,SAASM,OAAO,CAGMT;EAAME;EAAYC;CAAQ;AACrD;;;;;;;;;;;;;;;;AAoBA,SAAgBU,sCAAwE;CACtF,OAAOpB,2BAA2B;AACpC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@assistant-ui/react",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.27",
|
|
4
4
|
"description": "Open-source TypeScript/React library for building production-grade AI chat experiences",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
@@ -55,10 +55,10 @@
|
|
|
55
55
|
],
|
|
56
56
|
"sideEffects": false,
|
|
57
57
|
"dependencies": {
|
|
58
|
-
"@assistant-ui/core": "^0.3.
|
|
59
|
-
"@assistant-ui/store": "^0.3.
|
|
58
|
+
"@assistant-ui/core": "^0.3.26",
|
|
59
|
+
"@assistant-ui/store": "^0.3.19",
|
|
60
60
|
"@assistant-ui/tap": "^0.9.22",
|
|
61
|
-
"assistant-cloud": "^0.2.
|
|
61
|
+
"assistant-cloud": "^0.2.7",
|
|
62
62
|
"assistant-stream": "^0.3.49",
|
|
63
63
|
"radix-ui": "^1.7.0",
|
|
64
64
|
"react-textarea-autosize": "^8.5.9",
|
package/src/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type {
|
|
|
11
11
|
McpAppBridgeHandlers,
|
|
12
12
|
McpAppFrameProps,
|
|
13
13
|
McpAppHostContext,
|
|
14
|
+
McpAppInitializeParams,
|
|
14
15
|
McpAppsHost,
|
|
15
16
|
McpAppsRemoteHostOptions,
|
|
16
17
|
} from "./types";
|
|
@@ -229,8 +230,12 @@ describe("McpAppRenderer", () => {
|
|
|
229
230
|
await waitFor(() => expect(framePropsCalls().length).toBeGreaterThan(0));
|
|
230
231
|
const handlers = framePropsCalls().at(-1)?.handlers;
|
|
231
232
|
expect(handlers?.requestDisplayMode).toBeDefined();
|
|
232
|
-
|
|
233
|
+
const params: McpAppInitializeParams = {
|
|
234
|
+
appCapabilities: { availableDisplayModes: ["inline", "fullscreen"] },
|
|
235
|
+
};
|
|
236
|
+
handlers?.onInitialized?.(params);
|
|
233
237
|
expect(onInitialized).toHaveBeenCalledOnce();
|
|
238
|
+
expect(onInitialized).toHaveBeenCalledWith(params);
|
|
234
239
|
});
|
|
235
240
|
|
|
236
241
|
it("leaves mounted parts alone when renderer options are unchanged", async () => {
|
|
@@ -759,7 +764,7 @@ describe("McpAppRenderer", () => {
|
|
|
759
764
|
await handlers.updateModelContext?.({ text: "context" });
|
|
760
765
|
await handlers.openLink?.({ url: "https://example.com" });
|
|
761
766
|
await handlers.sendMessage?.({ text: "hello" });
|
|
762
|
-
handlers.onInitialized?.();
|
|
767
|
+
handlers.onInitialized?.({});
|
|
763
768
|
await handlers.callTool?.({ name: "search" });
|
|
764
769
|
await handlers.readResource?.({ uri: "ui://resource" });
|
|
765
770
|
await handlers.listResources?.();
|
|
@@ -8,7 +8,11 @@ import type {
|
|
|
8
8
|
} from "../sandbox-host/SandboxHost";
|
|
9
9
|
import type { CreateMcpAppBridgeOptions, McpAppBridge } from "./bridge";
|
|
10
10
|
import type * as BridgeModule from "./bridge";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
MCP_APP_MIME_TYPE,
|
|
13
|
+
type McpAppHostContext,
|
|
14
|
+
type McpAppInitializeParams,
|
|
15
|
+
} from "./types";
|
|
12
16
|
|
|
13
17
|
const { sandboxHostMock, createMcpAppBridgeMock } = vi.hoisted(() => ({
|
|
14
18
|
sandboxHostMock: vi.fn(),
|
|
@@ -272,6 +276,60 @@ describe("McpAppFrame", () => {
|
|
|
272
276
|
}
|
|
273
277
|
});
|
|
274
278
|
|
|
279
|
+
it("forwards initialization metadata to the live handler", () => {
|
|
280
|
+
let createBridge: SandboxHostProps["createBridge"] | null = null;
|
|
281
|
+
sandboxHostMock.mockImplementation((props: SandboxHostProps) => {
|
|
282
|
+
createBridge ??= props.createBridge;
|
|
283
|
+
return null;
|
|
284
|
+
});
|
|
285
|
+
const bridge: McpAppBridge = {
|
|
286
|
+
onMessage: vi.fn(),
|
|
287
|
+
dispose: vi.fn(),
|
|
288
|
+
notifyToolInput: vi.fn(),
|
|
289
|
+
notifyToolResult: vi.fn(),
|
|
290
|
+
notifyHostContextChanged: vi.fn(),
|
|
291
|
+
};
|
|
292
|
+
createMcpAppBridgeMock.mockReturnValue(bridge);
|
|
293
|
+
const onInitialized = vi.fn();
|
|
294
|
+
const view = (handler: typeof onInitialized) => (
|
|
295
|
+
<McpAppFrame
|
|
296
|
+
app={{ resourceUri: "ui://example/widget" }}
|
|
297
|
+
resource={{
|
|
298
|
+
uri: "ui://example/widget",
|
|
299
|
+
mimeType: MCP_APP_MIME_TYPE,
|
|
300
|
+
html: "",
|
|
301
|
+
}}
|
|
302
|
+
handlers={{ onInitialized: handler }}
|
|
303
|
+
/>
|
|
304
|
+
);
|
|
305
|
+
const rendered = render(view(onInitialized));
|
|
306
|
+
|
|
307
|
+
const sandboxBridge = createBridge!(
|
|
308
|
+
{
|
|
309
|
+
iframe: document.createElement("iframe"),
|
|
310
|
+
origin: "https://widget.example",
|
|
311
|
+
sendMessage: vi.fn(),
|
|
312
|
+
},
|
|
313
|
+
{ setHeight: vi.fn() },
|
|
314
|
+
);
|
|
315
|
+
const options = createMcpAppBridgeMock.mock
|
|
316
|
+
.calls[0]![0] as CreateMcpAppBridgeOptions;
|
|
317
|
+
const replacementOnInitialized = vi.fn();
|
|
318
|
+
const params: McpAppInitializeParams = {
|
|
319
|
+
protocolVersion: "2026-01-26",
|
|
320
|
+
appInfo: { name: "example-app", version: "1.2.3" },
|
|
321
|
+
appCapabilities: { availableDisplayModes: ["inline", "pip"] },
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
rendered.rerender(view(replacementOnInitialized));
|
|
325
|
+
options.handlers?.onInitialized?.(params);
|
|
326
|
+
|
|
327
|
+
expect(onInitialized).not.toHaveBeenCalled();
|
|
328
|
+
expect(replacementOnInitialized).toHaveBeenCalledOnce();
|
|
329
|
+
expect(replacementOnInitialized).toHaveBeenCalledWith(params);
|
|
330
|
+
sandboxBridge.dispose();
|
|
331
|
+
});
|
|
332
|
+
|
|
275
333
|
it("disposes the underlying bridge with the sandbox bridge", () => {
|
|
276
334
|
let createBridge: SandboxHostProps["createBridge"] | null = null;
|
|
277
335
|
sandboxHostMock.mockImplementation((props: SandboxHostProps) => {
|
|
@@ -350,7 +408,7 @@ describe("McpAppFrame", () => {
|
|
|
350
408
|
);
|
|
351
409
|
const options = createMcpAppBridgeMock.mock
|
|
352
410
|
.calls[0]![0] as CreateMcpAppBridgeOptions;
|
|
353
|
-
options.handlers?.onInitialized?.();
|
|
411
|
+
options.handlers?.onInitialized?.({});
|
|
354
412
|
|
|
355
413
|
rendered.rerender(
|
|
356
414
|
view({ displayMode: "inline", availableDisplayModes: ["inline", "pip"] }),
|
|
@@ -429,7 +487,7 @@ describe("McpAppFrame", () => {
|
|
|
429
487
|
|
|
430
488
|
const options = createMcpAppBridgeMock.mock
|
|
431
489
|
.calls[0]![0] as CreateMcpAppBridgeOptions;
|
|
432
|
-
options.handlers?.onInitialized?.();
|
|
490
|
+
options.handlers?.onInitialized?.({});
|
|
433
491
|
|
|
434
492
|
expect(bridge.notifyHostContextChanged).not.toHaveBeenCalled();
|
|
435
493
|
|
|
@@ -474,7 +532,7 @@ describe("McpAppFrame", () => {
|
|
|
474
532
|
);
|
|
475
533
|
const options = createMcpAppBridgeMock.mock
|
|
476
534
|
.calls[0]![0] as CreateMcpAppBridgeOptions;
|
|
477
|
-
options.handlers?.onInitialized?.();
|
|
535
|
+
options.handlers?.onInitialized?.({});
|
|
478
536
|
|
|
479
537
|
rendered.rerender(view());
|
|
480
538
|
expect(bridge.notifyHostContextChanged).not.toHaveBeenCalled();
|
|
@@ -519,7 +577,7 @@ describe("McpAppFrame", () => {
|
|
|
519
577
|
);
|
|
520
578
|
const options = createMcpAppBridgeMock.mock
|
|
521
579
|
.calls[0]![0] as CreateMcpAppBridgeOptions;
|
|
522
|
-
options.handlers?.onInitialized?.();
|
|
580
|
+
options.handlers?.onInitialized?.({});
|
|
523
581
|
|
|
524
582
|
rendered.rerender(view(new Date(2)));
|
|
525
583
|
expect(bridge.notifyHostContextChanged).toHaveBeenCalledTimes(1);
|
|
@@ -565,7 +623,7 @@ describe("McpAppFrame", () => {
|
|
|
565
623
|
);
|
|
566
624
|
const options = createMcpAppBridgeMock.mock
|
|
567
625
|
.calls[0]![0] as CreateMcpAppBridgeOptions;
|
|
568
|
-
options.handlers?.onInitialized?.();
|
|
626
|
+
options.handlers?.onInitialized?.({});
|
|
569
627
|
|
|
570
628
|
rendered.rerender(view({}));
|
|
571
629
|
expect(bridge.notifyHostContextChanged).not.toHaveBeenCalled();
|
|
@@ -143,9 +143,9 @@ function buildLiveHandlers(
|
|
|
143
143
|
lifecycle.onSizeChange(p);
|
|
144
144
|
live()?.onSizeChange?.(p);
|
|
145
145
|
};
|
|
146
|
-
out.onInitialized = () => {
|
|
146
|
+
out.onInitialized = (params) => {
|
|
147
147
|
lifecycle.onInitialized();
|
|
148
|
-
live()?.onInitialized?.();
|
|
148
|
+
live()?.onInitialized?.(params);
|
|
149
149
|
};
|
|
150
150
|
out.onRequestTeardown = (p) => live()?.onRequestTeardown?.(p);
|
|
151
151
|
out.onLog = (p) => live()?.onLog?.(p);
|
|
@@ -197,6 +197,160 @@ describe("createMcpAppBridge", () => {
|
|
|
197
197
|
bridge.dispose();
|
|
198
198
|
});
|
|
199
199
|
|
|
200
|
+
it("passes view initialization metadata to onInitialized", () => {
|
|
201
|
+
const { frame } = makeFrame();
|
|
202
|
+
const onInitialized = vi.fn();
|
|
203
|
+
const bridge = createMcpAppBridge({ frame, handlers: { onInitialized } });
|
|
204
|
+
const params = {
|
|
205
|
+
protocolVersion: "2026-01-26",
|
|
206
|
+
appInfo: { name: "example-app", version: "1.2.3" },
|
|
207
|
+
appCapabilities: {
|
|
208
|
+
availableDisplayModes: ["inline", "fullscreen"],
|
|
209
|
+
tools: { listChanged: true },
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
deliver(bridge, {
|
|
214
|
+
jsonrpc: "2.0",
|
|
215
|
+
id: 1,
|
|
216
|
+
method: "ui/initialize",
|
|
217
|
+
params,
|
|
218
|
+
});
|
|
219
|
+
expect(onInitialized).not.toHaveBeenCalled();
|
|
220
|
+
deliver(bridge, {
|
|
221
|
+
jsonrpc: "2.0",
|
|
222
|
+
method: "notifications/initialized",
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
expect(onInitialized).toHaveBeenCalledOnce();
|
|
226
|
+
expect(onInitialized).toHaveBeenCalledWith(params);
|
|
227
|
+
bridge.dispose();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("omits malformed initialization fields and keeps the protocol fallback", async () => {
|
|
231
|
+
const { frame, captured } = makeFrame();
|
|
232
|
+
const onInitialized = vi.fn();
|
|
233
|
+
const bridge = createMcpAppBridge({ frame, handlers: { onInitialized } });
|
|
234
|
+
|
|
235
|
+
deliver(bridge, {
|
|
236
|
+
jsonrpc: "2.0",
|
|
237
|
+
id: 1,
|
|
238
|
+
method: "ui/initialize",
|
|
239
|
+
params: {
|
|
240
|
+
protocolVersion: 1,
|
|
241
|
+
appInfo: { name: "malformed" },
|
|
242
|
+
appCapabilities: { availableDisplayModes: "fullscreen" },
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
deliver(bridge, {
|
|
246
|
+
jsonrpc: "2.0",
|
|
247
|
+
method: "notifications/initialized",
|
|
248
|
+
});
|
|
249
|
+
await flush();
|
|
250
|
+
|
|
251
|
+
expect(onInitialized).toHaveBeenCalledOnce();
|
|
252
|
+
expect(onInitialized).toHaveBeenCalledWith({
|
|
253
|
+
appCapabilities: {},
|
|
254
|
+
});
|
|
255
|
+
expect((captured[0] as McpAppJsonRpcResponse).result).toMatchObject({
|
|
256
|
+
protocolVersion: MCP_APP_PROTOCOL_VERSION,
|
|
257
|
+
});
|
|
258
|
+
bridge.dispose();
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("keeps only the display modes the host recognizes", () => {
|
|
262
|
+
const { frame } = makeFrame();
|
|
263
|
+
const onInitialized = vi.fn();
|
|
264
|
+
const bridge = createMcpAppBridge({ frame, handlers: { onInitialized } });
|
|
265
|
+
|
|
266
|
+
deliver(bridge, {
|
|
267
|
+
jsonrpc: "2.0",
|
|
268
|
+
id: 1,
|
|
269
|
+
method: "ui/initialize",
|
|
270
|
+
params: {
|
|
271
|
+
appCapabilities: { availableDisplayModes: ["inline", "sidebar"] },
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
deliver(bridge, {
|
|
275
|
+
jsonrpc: "2.0",
|
|
276
|
+
method: "notifications/initialized",
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
expect(onInitialized).toHaveBeenCalledWith({
|
|
280
|
+
appCapabilities: { availableDisplayModes: ["inline"] },
|
|
281
|
+
});
|
|
282
|
+
bridge.dispose();
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("reads a sparse display mode list without walking its length", () => {
|
|
286
|
+
const { frame } = makeFrame();
|
|
287
|
+
const onInitialized = vi.fn();
|
|
288
|
+
const bridge = createMcpAppBridge({ frame, handlers: { onInitialized } });
|
|
289
|
+
const availableDisplayModes = ["inline"];
|
|
290
|
+
availableDisplayModes.length = 2 ** 32 - 1;
|
|
291
|
+
|
|
292
|
+
deliver(bridge, {
|
|
293
|
+
jsonrpc: "2.0",
|
|
294
|
+
id: 1,
|
|
295
|
+
method: "ui/initialize",
|
|
296
|
+
params: { appCapabilities: { availableDisplayModes } },
|
|
297
|
+
});
|
|
298
|
+
deliver(bridge, {
|
|
299
|
+
jsonrpc: "2.0",
|
|
300
|
+
method: "notifications/initialized",
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
expect(onInitialized).toHaveBeenCalledWith({
|
|
304
|
+
appCapabilities: { availableDisplayModes: ["inline"] },
|
|
305
|
+
});
|
|
306
|
+
bridge.dispose();
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("initializes when the display mode list carries its own methods", async () => {
|
|
310
|
+
const { frame, captured } = makeFrame();
|
|
311
|
+
const onInitialized = vi.fn();
|
|
312
|
+
const bridge = createMcpAppBridge({ frame, handlers: { onInitialized } });
|
|
313
|
+
|
|
314
|
+
deliver(bridge, {
|
|
315
|
+
jsonrpc: "2.0",
|
|
316
|
+
id: 1,
|
|
317
|
+
method: "ui/initialize",
|
|
318
|
+
params: {
|
|
319
|
+
appCapabilities: {
|
|
320
|
+
availableDisplayModes: Object.assign(["inline", "pip"], {
|
|
321
|
+
filter: 0,
|
|
322
|
+
constructor: 0,
|
|
323
|
+
}),
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
});
|
|
327
|
+
deliver(bridge, {
|
|
328
|
+
jsonrpc: "2.0",
|
|
329
|
+
method: "notifications/initialized",
|
|
330
|
+
});
|
|
331
|
+
await flush();
|
|
332
|
+
|
|
333
|
+
expect((captured[0] as McpAppJsonRpcResponse).error).toBeUndefined();
|
|
334
|
+
expect(onInitialized).toHaveBeenCalledWith({
|
|
335
|
+
appCapabilities: { availableDisplayModes: ["inline", "pip"] },
|
|
336
|
+
});
|
|
337
|
+
bridge.dispose();
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("calls onInitialized with empty params when ui/initialize never arrived", () => {
|
|
341
|
+
const { frame } = makeFrame();
|
|
342
|
+
const onInitialized = vi.fn();
|
|
343
|
+
const bridge = createMcpAppBridge({ frame, handlers: { onInitialized } });
|
|
344
|
+
|
|
345
|
+
deliver(bridge, {
|
|
346
|
+
jsonrpc: "2.0",
|
|
347
|
+
method: "notifications/initialized",
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
expect(onInitialized).toHaveBeenCalledWith({});
|
|
351
|
+
bridge.dispose();
|
|
352
|
+
});
|
|
353
|
+
|
|
200
354
|
it("routes tools/call to handler", async () => {
|
|
201
355
|
const { frame, captured } = makeFrame();
|
|
202
356
|
const callTool = vi.fn().mockResolvedValue({ ok: true });
|
package/src/mcp-apps/bridge.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
type McpAppDisplayMode,
|
|
7
7
|
type McpAppHostContext,
|
|
8
8
|
type McpAppHostInfo,
|
|
9
|
+
type McpAppInitializeParams,
|
|
9
10
|
type McpAppJsonRpcMessage,
|
|
10
11
|
type McpAppJsonRpcNotification,
|
|
11
12
|
type McpAppJsonRpcRequest,
|
|
@@ -19,6 +20,50 @@ const VALID_DISPLAY_MODES = [
|
|
|
19
20
|
"pip",
|
|
20
21
|
] as const satisfies readonly McpAppDisplayMode[];
|
|
21
22
|
|
|
23
|
+
function isMcpAppDisplayMode(value: unknown): value is McpAppDisplayMode {
|
|
24
|
+
return VALID_DISPLAY_MODES.some((mode) => mode === value);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isAppInfo(
|
|
28
|
+
value: unknown,
|
|
29
|
+
): value is NonNullable<McpAppInitializeParams["appInfo"]> {
|
|
30
|
+
return (
|
|
31
|
+
isRecord(value) &&
|
|
32
|
+
typeof value.name === "string" &&
|
|
33
|
+
typeof value.version === "string"
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function getAppCapabilities(
|
|
38
|
+
value: unknown,
|
|
39
|
+
): McpAppInitializeParams["appCapabilities"] {
|
|
40
|
+
if (!isRecord(value)) return undefined;
|
|
41
|
+
const { availableDisplayModes, ...rest } = value;
|
|
42
|
+
return {
|
|
43
|
+
...rest,
|
|
44
|
+
// Object.values reads only the entries present, so a sparse list claiming a huge length stays cheap, and the list's own methods are never called.
|
|
45
|
+
...(Array.isArray(availableDisplayModes)
|
|
46
|
+
? {
|
|
47
|
+
availableDisplayModes: Object.values(availableDisplayModes).filter(
|
|
48
|
+
isMcpAppDisplayMode,
|
|
49
|
+
),
|
|
50
|
+
}
|
|
51
|
+
: {}),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function getInitializeParams(params: unknown): McpAppInitializeParams {
|
|
56
|
+
if (!isRecord(params)) return {};
|
|
57
|
+
const appCapabilities = getAppCapabilities(params.appCapabilities);
|
|
58
|
+
return {
|
|
59
|
+
...(isAppInfo(params.appInfo) ? { appInfo: params.appInfo } : {}),
|
|
60
|
+
...(appCapabilities ? { appCapabilities } : {}),
|
|
61
|
+
...(typeof params.protocolVersion === "string"
|
|
62
|
+
? { protocolVersion: params.protocolVersion }
|
|
63
|
+
: {}),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
22
67
|
export type McpAppBridgeFrame = SandboxHostFrame;
|
|
23
68
|
|
|
24
69
|
export type CreateMcpAppBridgeOptions = {
|
|
@@ -91,6 +136,7 @@ export function createMcpAppBridge(
|
|
|
91
136
|
hostContext = {},
|
|
92
137
|
} = opts;
|
|
93
138
|
let disposed = false;
|
|
139
|
+
let initializeParams: McpAppInitializeParams = {};
|
|
94
140
|
|
|
95
141
|
const post = (msg: McpAppJsonRpcMessage) => {
|
|
96
142
|
if (disposed) return;
|
|
@@ -142,10 +188,9 @@ export function createMcpAppBridge(
|
|
|
142
188
|
|
|
143
189
|
switch (normalizeMethod(req.method)) {
|
|
144
190
|
case "ui/initialize": {
|
|
191
|
+
initializeParams = getInitializeParams(params);
|
|
145
192
|
const requestedProtocolVersion =
|
|
146
|
-
|
|
147
|
-
? params.protocolVersion
|
|
148
|
-
: MCP_APP_PROTOCOL_VERSION;
|
|
193
|
+
initializeParams.protocolVersion ?? MCP_APP_PROTOCOL_VERSION;
|
|
149
194
|
respond(req.id, {
|
|
150
195
|
result: {
|
|
151
196
|
protocolVersion: requestedProtocolVersion,
|
|
@@ -360,10 +405,7 @@ export function createMcpAppBridge(
|
|
|
360
405
|
return;
|
|
361
406
|
}
|
|
362
407
|
const modeParams = (params ?? {}) as { mode?: unknown };
|
|
363
|
-
if (
|
|
364
|
-
typeof modeParams.mode !== "string" ||
|
|
365
|
-
!VALID_DISPLAY_MODES.includes(modeParams.mode as McpAppDisplayMode)
|
|
366
|
-
) {
|
|
408
|
+
if (!isMcpAppDisplayMode(modeParams.mode)) {
|
|
367
409
|
errorResponse(
|
|
368
410
|
req.id,
|
|
369
411
|
JSONRPC_ERROR.invalidParams,
|
|
@@ -373,7 +415,7 @@ export function createMcpAppBridge(
|
|
|
373
415
|
}
|
|
374
416
|
respond(req.id, {
|
|
375
417
|
result: await handlers.requestDisplayMode({
|
|
376
|
-
mode: modeParams.mode
|
|
418
|
+
mode: modeParams.mode,
|
|
377
419
|
}),
|
|
378
420
|
});
|
|
379
421
|
return;
|
|
@@ -398,7 +440,7 @@ export function createMcpAppBridge(
|
|
|
398
440
|
try {
|
|
399
441
|
switch (normalizeMethod(note.method)) {
|
|
400
442
|
case "notifications/initialized": {
|
|
401
|
-
handlers.onInitialized?.();
|
|
443
|
+
handlers.onInitialized?.(initializeParams);
|
|
402
444
|
return;
|
|
403
445
|
}
|
|
404
446
|
case "notifications/size_changed": {
|
package/src/mcp-apps/index.ts
CHANGED
package/src/mcp-apps/types.ts
CHANGED
|
@@ -87,6 +87,20 @@ export type McpAppToolCallParams = {
|
|
|
87
87
|
arguments?: Record<string, unknown>;
|
|
88
88
|
};
|
|
89
89
|
|
|
90
|
+
/**
|
|
91
|
+
* The params of the app's `ui/initialize` request, as `onInitialized`
|
|
92
|
+
* receives them. A field that fails validation is omitted, and
|
|
93
|
+
* `availableDisplayModes` keeps only the modes the bridge recognizes.
|
|
94
|
+
*/
|
|
95
|
+
export type McpAppInitializeParams = {
|
|
96
|
+
appInfo?: { name: string; version: string; [k: string]: unknown };
|
|
97
|
+
appCapabilities?: {
|
|
98
|
+
availableDisplayModes?: McpAppDisplayMode[];
|
|
99
|
+
[k: string]: unknown;
|
|
100
|
+
};
|
|
101
|
+
protocolVersion?: string;
|
|
102
|
+
};
|
|
103
|
+
|
|
90
104
|
export type McpAppBridgeHandlers = {
|
|
91
105
|
allowedTools?: readonly string[];
|
|
92
106
|
callTool?: (params: McpAppToolCallParams) => Promise<unknown> | unknown;
|
|
@@ -99,7 +113,7 @@ export type McpAppBridgeHandlers = {
|
|
|
99
113
|
mode: McpAppDisplayMode;
|
|
100
114
|
}) => Promise<{ mode: McpAppDisplayMode }> | { mode: McpAppDisplayMode };
|
|
101
115
|
onSizeChange?: (params: { width?: number; height?: number }) => void;
|
|
102
|
-
onInitialized?: () => void;
|
|
116
|
+
onInitialized?: (params: McpAppInitializeParams) => void;
|
|
103
117
|
onRequestTeardown?: (params: unknown) => void;
|
|
104
118
|
onLog?: (params: unknown) => void;
|
|
105
119
|
onError?: (error: Error) => void;
|
|
@@ -6,9 +6,10 @@ import { createRoot, type Root } from "react-dom/client";
|
|
|
6
6
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
7
7
|
import { ComposerPrimitiveAttachmentDropzone } from "./ComposerAttachmentDropzone";
|
|
8
8
|
|
|
9
|
-
const { addAttachment, threadCapabilities } = vi.hoisted(() => ({
|
|
9
|
+
const { addAttachment, threadCapabilities, threadState } = vi.hoisted(() => ({
|
|
10
10
|
addAttachment: vi.fn<(file: File) => Promise<void>>(),
|
|
11
11
|
threadCapabilities: { attachments: true },
|
|
12
|
+
threadState: { isDisabled: false },
|
|
12
13
|
}));
|
|
13
14
|
|
|
14
15
|
(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
|
|
@@ -25,6 +26,9 @@ vi.mock("@assistant-ui/store", async (importOriginal) => {
|
|
|
25
26
|
getState: () => ({ capabilities: threadCapabilities }),
|
|
26
27
|
},
|
|
27
28
|
}),
|
|
29
|
+
useAuiState: (
|
|
30
|
+
selector: (state: { thread: typeof threadState }) => unknown,
|
|
31
|
+
) => selector({ thread: threadState }),
|
|
28
32
|
};
|
|
29
33
|
});
|
|
30
34
|
|
|
@@ -53,6 +57,7 @@ describe("ComposerPrimitiveAttachmentDropzone", () => {
|
|
|
53
57
|
beforeEach(async () => {
|
|
54
58
|
addAttachment.mockReset();
|
|
55
59
|
threadCapabilities.attachments = true;
|
|
60
|
+
threadState.isDisabled = false;
|
|
56
61
|
container = document.createElement("div");
|
|
57
62
|
document.body.appendChild(container);
|
|
58
63
|
root = createRoot(container);
|
|
@@ -250,6 +255,32 @@ describe("ComposerPrimitiveAttachmentDropzone", () => {
|
|
|
250
255
|
expect(dropzone.hasAttribute("data-dragging")).toBe(false);
|
|
251
256
|
});
|
|
252
257
|
|
|
258
|
+
it("blocks file drops when the thread is disabled", async () => {
|
|
259
|
+
threadState.isDisabled = true;
|
|
260
|
+
await act(async () => {
|
|
261
|
+
root.render(
|
|
262
|
+
<ComposerPrimitiveAttachmentDropzone data-testid="dropzone">
|
|
263
|
+
<div>dropzone</div>
|
|
264
|
+
</ComposerPrimitiveAttachmentDropzone>,
|
|
265
|
+
);
|
|
266
|
+
});
|
|
267
|
+
const dropzone = container.querySelector("[data-testid='dropzone']")!;
|
|
268
|
+
const over = createDragEvent("dragover", ["Files"]);
|
|
269
|
+
const drop = createDropEvent([
|
|
270
|
+
new File(["file"], "photo.png", { type: "image/png" }),
|
|
271
|
+
]);
|
|
272
|
+
|
|
273
|
+
await act(async () => {
|
|
274
|
+
dropzone.dispatchEvent(over);
|
|
275
|
+
dropzone.dispatchEvent(drop);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
expect(over.defaultPrevented).toBe(true);
|
|
279
|
+
expect(drop.defaultPrevented).toBe(true);
|
|
280
|
+
expect(addAttachment).not.toHaveBeenCalled();
|
|
281
|
+
expect(dropzone.hasAttribute("data-dragging")).toBe(false);
|
|
282
|
+
});
|
|
283
|
+
|
|
253
284
|
it("ignores non-file drags", async () => {
|
|
254
285
|
const dropzone = container.querySelector("[data-testid='dropzone']");
|
|
255
286
|
expect(dropzone).not.toBeNull();
|