@assistant-ui/react 0.15.18 → 0.15.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.md +7 -1
  2. package/dist/context/providers/ThreadViewportProvider.js +2 -2
  3. package/dist/context/providers/ThreadViewportProvider.js.map +1 -1
  4. package/dist/hooks/useToolCallElapsed.js.map +1 -1
  5. package/dist/index.d.ts +2 -2
  6. package/dist/index.js +2 -2
  7. package/dist/mcp-apps/app-frame.d.ts.map +1 -1
  8. package/dist/mcp-apps/app-frame.js +17 -12
  9. package/dist/mcp-apps/app-frame.js.map +1 -1
  10. package/dist/mcp-apps/bridge.js +7 -1
  11. package/dist/mcp-apps/bridge.js.map +1 -1
  12. package/dist/primitives/assistantModal/AssistantModalRoot.d.ts.map +1 -1
  13. package/dist/primitives/assistantModal/AssistantModalRoot.js +47 -79
  14. package/dist/primitives/assistantModal/AssistantModalRoot.js.map +1 -1
  15. package/dist/primitives/composer/ComposerAttachmentDropzone.d.ts.map +1 -1
  16. package/dist/primitives/composer/ComposerAttachmentDropzone.js +61 -138
  17. package/dist/primitives/composer/ComposerAttachmentDropzone.js.map +1 -1
  18. package/dist/primitives/composer/ComposerInput.js +3 -8
  19. package/dist/primitives/composer/ComposerInput.js.map +1 -1
  20. package/dist/primitives/composer/trigger/triggerKeyboardResource.d.ts.map +1 -1
  21. package/dist/primitives/composer/trigger/triggerKeyboardResource.js +53 -61
  22. package/dist/primitives/composer/trigger/triggerKeyboardResource.js.map +1 -1
  23. package/dist/primitives/composer/trigger/triggerNavigationResource.d.ts.map +1 -1
  24. package/dist/primitives/composer/trigger/triggerNavigationResource.js +127 -137
  25. package/dist/primitives/composer/trigger/triggerNavigationResource.js.map +1 -1
  26. package/dist/primitives/message/MessageParts.js +2 -14
  27. package/dist/primitives/message/MessageParts.js.map +1 -1
  28. package/dist/primitives/message/MessagePartsGrouped.d.ts +3 -3
  29. package/dist/primitives/message/MessagePartsGrouped.js +2 -2
  30. package/dist/primitives/message/MessagePartsGrouped.js.map +1 -1
  31. package/dist/primitives/messagePart/MessagePartText.d.ts +3 -1
  32. package/dist/primitives/messagePart/MessagePartText.d.ts.map +1 -1
  33. package/dist/primitives/messagePart/MessagePartText.js +13 -36
  34. package/dist/primitives/messagePart/MessagePartText.js.map +1 -1
  35. package/dist/primitives/reasoning/useScrollLock.js +3 -1
  36. package/dist/primitives/reasoning/useScrollLock.js.map +1 -1
  37. package/dist/sandbox-host/SandboxHost.js +22 -4
  38. package/dist/sandbox-host/SandboxHost.js.map +1 -1
  39. package/dist/unstable/useLiveCompletionAdapter.js +24 -2
  40. package/dist/unstable/useLiveCompletionAdapter.js.map +1 -1
  41. package/dist/unstable/useMentionAdapter.js +2 -2
  42. package/dist/unstable/useMentionAdapter.js.map +1 -1
  43. package/dist/unstable/useMessageStallDetection.d.ts +4 -5
  44. package/dist/unstable/useMessageStallDetection.d.ts.map +1 -1
  45. package/dist/unstable/useMessageStallDetection.js +16 -15
  46. package/dist/unstable/useMessageStallDetection.js.map +1 -1
  47. package/dist/unstable/webmcp/convertTools.d.ts.map +1 -1
  48. package/dist/unstable/webmcp/convertTools.js +54 -3
  49. package/dist/unstable/webmcp/convertTools.js.map +1 -1
  50. package/dist/unstable/webmcp/useWebMcpProvider.d.ts.map +1 -1
  51. package/dist/unstable/webmcp/useWebMcpProvider.js +2 -1
  52. package/dist/unstable/webmcp/useWebMcpProvider.js.map +1 -1
  53. package/dist/utils/Primitive.d.ts +14 -6
  54. package/dist/utils/Primitive.d.ts.map +1 -1
  55. package/dist/utils/Primitive.js +25 -6
  56. package/dist/utils/Primitive.js.map +1 -1
  57. package/dist/utils/getSelectionMessageId.d.ts.map +1 -1
  58. package/dist/utils/getSelectionMessageId.js +17 -3
  59. package/dist/utils/getSelectionMessageId.js.map +1 -1
  60. package/package.json +11 -11
  61. package/src/context/providers/ThreadViewportProvider.tsx +2 -2
  62. package/src/hooks/useToolCallElapsed.ts +3 -0
  63. package/src/index.ts +1 -1
  64. package/src/mcp-apps/app-frame.test.tsx +189 -0
  65. package/src/mcp-apps/app-frame.tsx +31 -22
  66. package/src/mcp-apps/bridge.test.ts +98 -0
  67. package/src/mcp-apps/bridge.ts +7 -1
  68. package/src/primitives/assistantModal/AssistantModalRoot.test.tsx +123 -0
  69. package/src/primitives/assistantModal/AssistantModalRoot.tsx +11 -31
  70. package/src/primitives/assistantModal/AssistantModalTrigger.test.tsx +1 -16
  71. package/src/primitives/composer/ComposerAttachmentDropzone.test.tsx +43 -0
  72. package/src/primitives/composer/ComposerAttachmentDropzone.tsx +2 -10
  73. package/src/primitives/composer/ComposerInput.test.tsx +22 -0
  74. package/src/primitives/composer/ComposerInput.tsx +5 -9
  75. package/src/primitives/composer/trigger/triggerKeyboardResource.ts +13 -7
  76. package/src/primitives/composer/trigger/triggerNavigationResource.ts +5 -3
  77. package/src/primitives/message/MessageParts.tsx +1 -21
  78. package/src/primitives/message/MessagePartsGrouped.test.tsx +93 -0
  79. package/src/primitives/message/MessagePartsGrouped.tsx +6 -6
  80. package/src/primitives/messagePart/MessagePartText.test.tsx +69 -0
  81. package/src/primitives/messagePart/MessagePartText.tsx +22 -9
  82. package/src/primitives/reasoning/useScrollLock.test.tsx +129 -0
  83. package/src/primitives/reasoning/useScrollLock.ts +15 -1
  84. package/src/primitives/thread/useThreadViewportAutoScroll.test.tsx +38 -1
  85. package/src/sandbox-host/SandboxHost.test.tsx +40 -0
  86. package/src/sandbox-host/SandboxHost.tsx +24 -4
  87. package/src/tests/MessageParts.rendererOptions.test.tsx +117 -0
  88. package/src/tests/toolCallTiming.test.tsx +55 -1
  89. package/src/unstable/useLiveCompletionAdapter.test.tsx +202 -1
  90. package/src/unstable/useLiveCompletionAdapter.ts +38 -8
  91. package/src/unstable/useMentionAdapter.test.tsx +47 -1
  92. package/src/unstable/useMentionAdapter.ts +2 -2
  93. package/src/unstable/useMessageStallDetection.ts +26 -19
  94. package/src/unstable/webmcp/convertTools.test.ts +181 -21
  95. package/src/unstable/webmcp/convertTools.ts +87 -7
  96. package/src/unstable/webmcp/useWebMcpProvider.ts +2 -4
  97. package/src/utils/Primitive.test.tsx +46 -1
  98. package/src/utils/Primitive.tsx +37 -7
  99. package/src/utils/getSelectionMessageId.test.ts +176 -1
  100. package/src/utils/getSelectionMessageId.ts +21 -3
@@ -1 +1 @@
1
- {"version":3,"file":"getSelectionMessageId.js","names":["QUOTE_SELECTABLE_SELECTOR","getElement","node","Node","HTMLElement","parentElement","findMessageElement","el","id","getAttribute","isExcluded","marker","Element","hasQuoteSelectableRegion","messageElement","matches","querySelectorAll","findQuoteMarker","closest","contains","getSelectionMessageId","selection","Selection","anchorNode","focusNode","anchorMessageElement","focusMessageElement","messageId","anchorMarker","focusMarker"],"sources":["../../src/utils/getSelectionMessageId.ts"],"sourcesContent":["const QUOTE_SELECTABLE_SELECTOR = \"[data-aui-quote-selectable]\";\n\nconst getElement = (node: Node | null): HTMLElement | null => {\n return node instanceof HTMLElement ? node : (node?.parentElement ?? null);\n};\n\nconst findMessageElement = (node: Node | null): HTMLElement | null => {\n let el = node instanceof HTMLElement ? node : (node?.parentElement ?? null);\n while (el) {\n const id = el.getAttribute(\"data-message-id\");\n if (id) return el;\n el = el.parentElement;\n }\n return null;\n};\n\nconst isExcluded = (marker: Element): boolean => {\n return marker.getAttribute(\"data-aui-quote-selectable\") === \"false\";\n};\n\nconst hasQuoteSelectableRegion = (messageElement: HTMLElement) => {\n if (\n messageElement.matches(QUOTE_SELECTABLE_SELECTOR) &&\n !isExcluded(messageElement)\n ) {\n return true;\n }\n for (const marker of messageElement.querySelectorAll(\n QUOTE_SELECTABLE_SELECTOR,\n )) {\n if (!isExcluded(marker)) return true;\n }\n return false;\n};\n\nconst findQuoteMarker = (\n node: Node | null,\n messageElement: HTMLElement,\n): HTMLElement | null => {\n const marker = getElement(node)?.closest(QUOTE_SELECTABLE_SELECTOR);\n if (!(marker instanceof HTMLElement)) return null;\n if (!messageElement.contains(marker)) return null;\n return marker;\n};\n\nexport const getSelectionMessageId = (selection: Selection): string | null => {\n const { anchorNode, focusNode } = selection;\n if (!anchorNode || !focusNode) return null;\n\n const anchorMessageElement = findMessageElement(anchorNode);\n const focusMessageElement = findMessageElement(focusNode);\n\n if (!anchorMessageElement || anchorMessageElement !== focusMessageElement) {\n return null;\n }\n\n const messageId = anchorMessageElement.getAttribute(\"data-message-id\");\n if (!messageId) return null;\n\n const anchorMarker = findQuoteMarker(anchorNode, anchorMessageElement);\n const focusMarker = findQuoteMarker(focusNode, anchorMessageElement);\n\n if (anchorMarker && isExcluded(anchorMarker)) return null;\n if (focusMarker && isExcluded(focusMarker)) return null;\n\n if (!hasQuoteSelectableRegion(anchorMessageElement)) return messageId;\n\n if (!anchorMarker || anchorMarker !== focusMarker) return null;\n\n return messageId;\n};\n"],"mappings":";AAAA,MAAMA,4BAA4B;AAElC,MAAMC,cAAcC,SAA0C;CAC5D,OAAOA,gBAAgBE,cAAcF,OAAQA,MAAMG,iBAAiB;AACtE;AAEA,MAAMC,sBAAsBJ,SAA0C;CACpE,IAAIK,KAAKL,gBAAgBE,cAAcF,OAAQA,MAAMG,iBAAiB;CACtE,OAAOE,IAAI;EAET,IADWA,GAAGE,aAAa,iBACvBD,GAAI,OAAOD;EACfA,KAAKA,GAAGF;CACV;CACA,OAAO;AACT;AAEA,MAAMK,cAAcC,WAA6B;CAC/C,OAAOA,OAAOF,aAAa,2BAA2B,MAAM;AAC9D;AAEA,MAAMI,4BAA4BC,mBAAgC;CAChE,IACEA,eAAeC,QAAQf,yBAAyB,KAChD,CAACU,WAAWI,cAAc,GAE1B,OAAO;CAET,KAAK,MAAMH,UAAUG,eAAeE,iBAClChB,yBACF,GACE,IAAI,CAACU,WAAWC,MAAM,GAAG,OAAO;CAElC,OAAO;AACT;AAEA,MAAMM,mBACJf,MACAY,mBACuB;CACvB,MAAMH,SAASV,WAAWC,IAAI,CAAC,EAAEgB,QAAQlB,yBAAyB;CAClE,IAAI,EAAEW,kBAAkBP,cAAc,OAAO;CAC7C,IAAI,CAACU,eAAeK,SAASR,MAAM,GAAG,OAAO;CAC7C,OAAOA;AACT;AAEA,MAAaS,yBAAyBC,cAAwC;CAC5E,MAAM,EAAEE,YAAYC,cAAcH;CAClC,IAAI,CAACE,cAAc,CAACC,WAAW,OAAO;CAEtC,MAAMC,uBAAuBnB,mBAAmBiB,UAAU;CAC1D,MAAMG,sBAAsBpB,mBAAmBkB,SAAS;CAExD,IAAI,CAACC,wBAAwBA,yBAAyBC,qBACpD,OAAO;CAGT,MAAMC,YAAYF,qBAAqBhB,aAAa,iBAAiB;CACrE,IAAI,CAACkB,WAAW,OAAO;CAEvB,MAAMC,eAAeX,gBAAgBM,YAAYE,oBAAoB;CACrE,MAAMI,cAAcZ,gBAAgBO,WAAWC,oBAAoB;CAEnE,IAAIG,gBAAgBlB,WAAWkB,YAAY,GAAG,OAAO;CACrD,IAAIC,eAAenB,WAAWmB,WAAW,GAAG,OAAO;CAEnD,IAAI,CAAChB,yBAAyBY,oBAAoB,GAAG,OAAOE;CAE5D,IAAI,CAACC,gBAAgBA,iBAAiBC,aAAa,OAAO;CAE1D,OAAOF;AACT"}
1
+ {"version":3,"file":"getSelectionMessageId.js","names":["QUOTE_SELECTABLE_SELECTOR","getElement","node","Node","HTMLElement","parentElement","findMessageElement","el","id","getAttribute","isExcluded","marker","Element","hasQuoteSelectableRegion","messageElement","matches","querySelectorAll","findQuoteMarker","closest","contains","intersectsExcluded","scope","selection","Selection","ranges","Array","from","length","rangeCount","_","i","getRangeAt","some","range","intersectsNode","getSelectionMessageId","anchorNode","focusNode","anchorMessageElement","focusMessageElement","messageId","anchorMarker","focusMarker","commonAncestorContainer"],"sources":["../../src/utils/getSelectionMessageId.ts"],"sourcesContent":["const QUOTE_SELECTABLE_SELECTOR = \"[data-aui-quote-selectable]\";\n\nconst getElement = (node: Node | null): HTMLElement | null => {\n return node instanceof HTMLElement ? node : (node?.parentElement ?? null);\n};\n\nconst findMessageElement = (node: Node | null): HTMLElement | null => {\n let el = node instanceof HTMLElement ? node : (node?.parentElement ?? null);\n while (el) {\n const id = el.getAttribute(\"data-message-id\");\n if (id) return el;\n el = el.parentElement;\n }\n return null;\n};\n\nconst isExcluded = (marker: Element): boolean => {\n return marker.getAttribute(\"data-aui-quote-selectable\") === \"false\";\n};\n\nconst hasQuoteSelectableRegion = (messageElement: HTMLElement) => {\n if (\n messageElement.matches(QUOTE_SELECTABLE_SELECTOR) &&\n !isExcluded(messageElement)\n ) {\n return true;\n }\n for (const marker of messageElement.querySelectorAll(\n QUOTE_SELECTABLE_SELECTOR,\n )) {\n if (!isExcluded(marker)) return true;\n }\n return false;\n};\n\nconst findQuoteMarker = (\n node: Node | null,\n messageElement: HTMLElement,\n): HTMLElement | null => {\n const marker = getElement(node)?.closest(QUOTE_SELECTABLE_SELECTOR);\n if (!(marker instanceof HTMLElement)) return null;\n if (!messageElement.contains(marker)) return null;\n return marker;\n};\n\nconst intersectsExcluded = (scope: Element, selection: Selection): boolean => {\n const ranges = Array.from({ length: selection.rangeCount }, (_, i) =>\n selection.getRangeAt(i),\n );\n for (const marker of scope.querySelectorAll(QUOTE_SELECTABLE_SELECTOR)) {\n if (!isExcluded(marker)) continue;\n if (ranges.some((range) => range.intersectsNode(marker))) return true;\n }\n return false;\n};\n\nexport const getSelectionMessageId = (selection: Selection): string | null => {\n const { anchorNode, focusNode } = selection;\n if (!anchorNode || !focusNode) return null;\n\n const anchorMessageElement = findMessageElement(anchorNode);\n const focusMessageElement = findMessageElement(focusNode);\n\n if (!anchorMessageElement || anchorMessageElement !== focusMessageElement) {\n return null;\n }\n\n const messageId = anchorMessageElement.getAttribute(\"data-message-id\");\n if (!messageId) return null;\n\n const anchorMarker = findQuoteMarker(anchorNode, anchorMessageElement);\n const focusMarker = findQuoteMarker(focusNode, anchorMessageElement);\n\n if (anchorMarker && isExcluded(anchorMarker)) return null;\n if (focusMarker && isExcluded(focusMarker)) return null;\n\n if (hasQuoteSelectableRegion(anchorMessageElement)) {\n if (!anchorMarker || anchorMarker !== focusMarker) return null;\n }\n\n const scope = anchorMarker ?? anchorMessageElement;\n\n for (let i = 0; i < selection.rangeCount; i++) {\n const { commonAncestorContainer } = selection.getRangeAt(i);\n if (!scope.contains(commonAncestorContainer)) return null;\n }\n\n return intersectsExcluded(scope, selection) ? null : messageId;\n};\n"],"mappings":";AAAA,MAAMA,4BAA4B;AAElC,MAAMC,cAAcC,SAA0C;CAC5D,OAAOA,gBAAgBE,cAAcF,OAAQA,MAAMG,iBAAiB;AACtE;AAEA,MAAMC,sBAAsBJ,SAA0C;CACpE,IAAIK,KAAKL,gBAAgBE,cAAcF,OAAQA,MAAMG,iBAAiB;CACtE,OAAOE,IAAI;EAET,IADWA,GAAGE,aAAa,iBACvBD,GAAI,OAAOD;EACfA,KAAKA,GAAGF;CACV;CACA,OAAO;AACT;AAEA,MAAMK,cAAcC,WAA6B;CAC/C,OAAOA,OAAOF,aAAa,2BAA2B,MAAM;AAC9D;AAEA,MAAMI,4BAA4BC,mBAAgC;CAChE,IACEA,eAAeC,QAAQf,yBAAyB,KAChD,CAACU,WAAWI,cAAc,GAE1B,OAAO;CAET,KAAK,MAAMH,UAAUG,eAAeE,iBAClChB,yBACF,GACE,IAAI,CAACU,WAAWC,MAAM,GAAG,OAAO;CAElC,OAAO;AACT;AAEA,MAAMM,mBACJf,MACAY,mBACuB;CACvB,MAAMH,SAASV,WAAWC,IAAI,CAAC,EAAEgB,QAAQlB,yBAAyB;CAClE,IAAI,EAAEW,kBAAkBP,cAAc,OAAO;CAC7C,IAAI,CAACU,eAAeK,SAASR,MAAM,GAAG,OAAO;CAC7C,OAAOA;AACT;AAEA,MAAMS,sBAAsBC,OAAgBC,cAAkC;CAC5E,MAAME,SAASC,MAAMC,KAAK,EAAEC,QAAQL,UAAUM,WAAW,IAAIC,GAAGC,MAC9DR,UAAUS,WAAWD,CAAC,CACxB;CACA,KAAK,MAAMnB,UAAUU,MAAML,iBAAiBhB,yBAAyB,GAAG;EACtE,IAAI,CAACU,WAAWC,MAAM,GAAG;EACzB,IAAIa,OAAOQ,MAAMC,UAAUA,MAAMC,eAAevB,MAAM,CAAC,GAAG,OAAO;CACnE;CACA,OAAO;AACT;AAEA,MAAawB,yBAAyBb,cAAwC;CAC5E,MAAM,EAAEc,YAAYC,cAAcf;CAClC,IAAI,CAACc,cAAc,CAACC,WAAW,OAAO;CAEtC,MAAMC,uBAAuBhC,mBAAmB8B,UAAU;CAC1D,MAAMG,sBAAsBjC,mBAAmB+B,SAAS;CAExD,IAAI,CAACC,wBAAwBA,yBAAyBC,qBACpD,OAAO;CAGT,MAAMC,YAAYF,qBAAqB7B,aAAa,iBAAiB;CACrE,IAAI,CAAC+B,WAAW,OAAO;CAEvB,MAAMC,eAAexB,gBAAgBmB,YAAYE,oBAAoB;CACrE,MAAMI,cAAczB,gBAAgBoB,WAAWC,oBAAoB;CAEnE,IAAIG,gBAAgB/B,WAAW+B,YAAY,GAAG,OAAO;CACrD,IAAIC,eAAehC,WAAWgC,WAAW,GAAG,OAAO;CAEnD,IAAI7B,yBAAyByB,oBAAoB,GAC3C;MAAA,CAACG,gBAAgBA,iBAAiBC,aAAa,OAAO;CAAA;CAG5D,MAAMrB,QAAQoB,gBAAgBH;CAE9B,KAAK,IAAIR,IAAI,GAAGA,IAAIR,UAAUM,YAAYE,KAAK;EAC7C,MAAM,EAAEa,4BAA4BrB,UAAUS,WAAWD,CAAC;EAC1D,IAAI,CAACT,MAAMF,SAASwB,uBAAuB,GAAG,OAAO;CACvD;CAEA,OAAOvB,mBAAmBC,OAAOC,SAAS,IAAI,OAAOkB;AACvD"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assistant-ui/react",
3
- "version": "0.15.18",
3
+ "version": "0.15.19",
4
4
  "description": "Open-source TypeScript/React library for building production-grade AI chat experiences",
5
5
  "keywords": [
6
6
  "ai",
@@ -55,14 +55,14 @@
55
55
  ],
56
56
  "sideEffects": false,
57
57
  "dependencies": {
58
- "@assistant-ui/core": "^0.3.17",
59
- "@assistant-ui/store": "^0.3.12",
60
- "@assistant-ui/tap": "^0.9.16",
61
- "assistant-cloud": "^0.1.43",
62
- "assistant-stream": "^0.3.41",
58
+ "@assistant-ui/core": "^0.3.18",
59
+ "@assistant-ui/store": "^0.3.13",
60
+ "@assistant-ui/tap": "^0.9.17",
61
+ "assistant-cloud": "^0.2.0",
62
+ "assistant-stream": "^0.3.42",
63
63
  "radix-ui": "^1.6.7",
64
64
  "react-textarea-autosize": "^8.5.9",
65
- "safe-content-frame": "^0.0.29",
65
+ "safe-content-frame": "^0.0.30",
66
66
  "zod": "^4.5.4",
67
67
  "zustand": "^5.0.15"
68
68
  },
@@ -81,13 +81,13 @@
81
81
  }
82
82
  },
83
83
  "devDependencies": {
84
- "@assistant-ui/vite": "0.0.16",
85
- "@assistant-ui/x-buildutils": "0.0.25",
84
+ "@assistant-ui/vite": "0.0.17",
85
+ "@assistant-ui/x-buildutils": "0.0.26",
86
86
  "@testing-library/react": "^16.3.3",
87
87
  "@types/json-schema": "^7.0.15",
88
- "@types/node": "^26.4.0",
88
+ "@types/node": "^26.4.1",
89
89
  "@types/react": "^19.2.18",
90
- "@types/react-dom": "^19.2.5",
90
+ "@types/react-dom": "^19.2.7",
91
91
  "jsdom": "^30.0.1",
92
92
  "react": "^19.2.8",
93
93
  "react-dom": "^19.2.8",
@@ -25,8 +25,8 @@ const useThreadViewportStoreValue = (options: ThreadViewportStoreOptions) => {
25
25
 
26
26
  // Forward scrollToBottom from outer viewport to inner viewport
27
27
  useEffect(() => {
28
- return outerViewport?.getState().onScrollToBottom(() => {
29
- store.getState().scrollToBottom();
28
+ return outerViewport?.getState().onScrollToBottom((config) => {
29
+ store.getState().scrollToBottom(config);
30
30
  });
31
31
  }, [outerViewport, store]);
32
32
 
@@ -36,6 +36,9 @@ export const useToolCallElapsed = (): number | undefined => {
36
36
 
37
37
  useEffect(() => {
38
38
  if (!running) return undefined;
39
+ // The clock is an external source; this catches the elapsed value up before
40
+ // the interval takes over.
41
+ // eslint-disable-next-line react-hooks/set-state-in-effect
39
42
  setNow(Date.now());
40
43
  const id = setInterval(() => setNow(Date.now()), 1000);
41
44
  return () => clearInterval(id);
package/src/index.ts CHANGED
@@ -46,7 +46,7 @@ export type {
46
46
  export { toolApprovalAcceptsText } from "@assistant-ui/core";
47
47
 
48
48
  export { useCloudThreadListRuntime } from "@assistant-ui/core/react";
49
- export { AssistantCloud } from "assistant-cloud";
49
+ export { AssistantCloud, readAnonymousRefreshToken } from "assistant-cloud";
50
50
 
51
51
  // --- adapters/attachment ---
52
52
  export type { AttachmentAdapter } from "@assistant-ui/core";
@@ -7,6 +7,7 @@ import type {
7
7
  SandboxHostProps,
8
8
  } from "../sandbox-host/SandboxHost";
9
9
  import type { CreateMcpAppBridgeOptions, McpAppBridge } from "./bridge";
10
+ import type * as BridgeModule from "./bridge";
10
11
  import { MCP_APP_MIME_TYPE, type McpAppHostContext } from "./types";
11
12
 
12
13
  const { sandboxHostMock, createMcpAppBridgeMock } = vi.hoisted(() => ({
@@ -100,6 +101,178 @@ describe("McpAppFrame", () => {
100
101
  sandboxBridge.dispose();
101
102
  });
102
103
 
104
+ it.each([
105
+ { initial: [], next: ["search"], allowed: true },
106
+ { initial: ["search"], next: [], allowed: false },
107
+ { initial: undefined, next: ["other"], allowed: false },
108
+ { initial: [], next: undefined, allowed: true },
109
+ ])(
110
+ "applies replacement tool allowlists: $initial -> $next",
111
+ async ({ initial, next, allowed }) => {
112
+ const { createMcpAppBridge } =
113
+ await vi.importActual<typeof BridgeModule>("./bridge");
114
+ createMcpAppBridgeMock.mockImplementationOnce(createMcpAppBridge);
115
+ const captured: { createBridge?: SandboxHostProps["createBridge"] } = {};
116
+ sandboxHostMock.mockImplementation((props: SandboxHostProps) => {
117
+ captured.createBridge ??= props.createBridge;
118
+ return null;
119
+ });
120
+ const callTool = vi.fn(() => ({ content: [] }));
121
+ const view = (allowedTools: readonly string[] | undefined) => (
122
+ <McpAppFrame
123
+ app={{ resourceUri: "ui://example/widget" }}
124
+ resource={{
125
+ uri: "ui://example/widget",
126
+ mimeType: MCP_APP_MIME_TYPE,
127
+ html: "",
128
+ }}
129
+ handlers={
130
+ allowedTools === undefined
131
+ ? { callTool }
132
+ : { allowedTools, callTool }
133
+ }
134
+ />
135
+ );
136
+ const rendered = render(view(initial));
137
+ if (!captured.createBridge) throw new Error("Frame did not mount");
138
+ const sendMessage = vi.fn();
139
+ const bridge = captured.createBridge(
140
+ {
141
+ iframe: document.createElement("iframe"),
142
+ origin: "https://widget.example",
143
+ sendMessage,
144
+ },
145
+ { setHeight: vi.fn() },
146
+ );
147
+ try {
148
+ rendered.rerender(view(next));
149
+ bridge.onMessage(
150
+ new MessageEvent("message", {
151
+ data: {
152
+ jsonrpc: "2.0",
153
+ id: 1,
154
+ method: "tools/call",
155
+ params: { name: "search" },
156
+ },
157
+ }),
158
+ );
159
+ await Promise.resolve();
160
+ expect(sendMessage).toHaveBeenCalledWith(
161
+ expect.objectContaining(
162
+ allowed
163
+ ? { id: 1, result: { content: [] } }
164
+ : { id: 1, error: expect.objectContaining({ code: -32602 }) },
165
+ ),
166
+ );
167
+ expect(callTool).toHaveBeenCalledTimes(allowed ? 1 : 0);
168
+ } finally {
169
+ bridge.dispose();
170
+ }
171
+ },
172
+ );
173
+
174
+ it("forwards size changes to the sandbox host and live handler", () => {
175
+ let createBridge: SandboxHostProps["createBridge"] | null = null;
176
+ sandboxHostMock.mockImplementation((props: SandboxHostProps) => {
177
+ createBridge ??= props.createBridge;
178
+ return null;
179
+ });
180
+ const bridge: McpAppBridge = {
181
+ onMessage: vi.fn(),
182
+ dispose: vi.fn(),
183
+ notifyToolInput: vi.fn(),
184
+ notifyToolResult: vi.fn(),
185
+ notifyHostContextChanged: vi.fn(),
186
+ };
187
+ createMcpAppBridgeMock.mockReturnValue(bridge);
188
+ const initialOnSizeChange = vi.fn();
189
+ const replacementOnSizeChange = vi.fn();
190
+ const view = (onSizeChange: typeof initialOnSizeChange) => (
191
+ <McpAppFrame
192
+ app={{ resourceUri: "ui://example/widget" }}
193
+ resource={{
194
+ uri: "ui://example/widget",
195
+ mimeType: MCP_APP_MIME_TYPE,
196
+ html: "",
197
+ }}
198
+ handlers={{ onSizeChange }}
199
+ />
200
+ );
201
+ const rendered = render(view(initialOnSizeChange));
202
+
203
+ const setHeight = vi.fn();
204
+ const sandboxBridge = createBridge!(
205
+ {
206
+ iframe: document.createElement("iframe"),
207
+ origin: "https://widget.example",
208
+ sendMessage: vi.fn(),
209
+ },
210
+ { setHeight },
211
+ );
212
+ const options = createMcpAppBridgeMock.mock
213
+ .calls[0]![0] as CreateMcpAppBridgeOptions;
214
+
215
+ try {
216
+ const initialSize = { width: 640, height: 360 };
217
+ options.handlers?.onSizeChange?.(initialSize);
218
+ expect(setHeight).toHaveBeenCalledWith(360);
219
+ expect(initialOnSizeChange).toHaveBeenCalledWith(initialSize);
220
+
221
+ rendered.rerender(view(replacementOnSizeChange));
222
+ const replacementSize = { width: 800, height: 480 };
223
+ options.handlers?.onSizeChange?.(replacementSize);
224
+ expect(setHeight).toHaveBeenNthCalledWith(2, 480);
225
+ expect(initialOnSizeChange).toHaveBeenCalledOnce();
226
+ expect(replacementOnSizeChange).toHaveBeenCalledOnce();
227
+ expect(replacementOnSizeChange).toHaveBeenCalledWith(replacementSize);
228
+
229
+ const widthOnlySize = { width: 720 };
230
+ options.handlers?.onSizeChange?.(widthOnlySize);
231
+ expect(setHeight).toHaveBeenCalledTimes(2);
232
+ expect(replacementOnSizeChange).toHaveBeenCalledWith(widthOnlySize);
233
+ } finally {
234
+ sandboxBridge.dispose();
235
+ }
236
+ });
237
+
238
+ it("disposes the underlying bridge with the sandbox bridge", () => {
239
+ let createBridge: SandboxHostProps["createBridge"] | null = null;
240
+ sandboxHostMock.mockImplementation((props: SandboxHostProps) => {
241
+ createBridge ??= props.createBridge;
242
+ return null;
243
+ });
244
+ const bridge: McpAppBridge = {
245
+ onMessage: vi.fn(),
246
+ dispose: vi.fn(),
247
+ notifyToolInput: vi.fn(),
248
+ notifyToolResult: vi.fn(),
249
+ notifyHostContextChanged: vi.fn(),
250
+ };
251
+ createMcpAppBridgeMock.mockReturnValue(bridge);
252
+ render(
253
+ <McpAppFrame
254
+ app={{ resourceUri: "ui://example/widget" }}
255
+ resource={{
256
+ uri: "ui://example/widget",
257
+ mimeType: MCP_APP_MIME_TYPE,
258
+ html: "",
259
+ }}
260
+ />,
261
+ );
262
+
263
+ const sandboxBridge = createBridge!(
264
+ {
265
+ iframe: document.createElement("iframe"),
266
+ origin: "https://widget.example",
267
+ sendMessage: vi.fn(),
268
+ },
269
+ { setHeight: vi.fn() },
270
+ );
271
+ sandboxBridge.dispose();
272
+
273
+ expect(bridge.dispose).toHaveBeenCalledOnce();
274
+ });
275
+
103
276
  it("only notifies the widget when host context actually changes", () => {
104
277
  let createBridge: SandboxHostProps["createBridge"] | null = null;
105
278
  sandboxHostMock.mockImplementation((props: SandboxHostProps) => {
@@ -159,6 +332,22 @@ describe("McpAppFrame", () => {
159
332
  availableDisplayModes: ["inline", "pip"],
160
333
  });
161
334
 
335
+ // A hole is not a value, in either operand order.
336
+ const sparseModes: ("inline" | "pip")[] = new Array(2);
337
+ sparseModes[1] = "pip";
338
+ rendered.rerender(
339
+ view({ displayMode: "fullscreen", availableDisplayModes: sparseModes }),
340
+ );
341
+ expect(bridge.notifyHostContextChanged).toHaveBeenCalledTimes(2);
342
+
343
+ rendered.rerender(
344
+ view({
345
+ displayMode: "fullscreen",
346
+ availableDisplayModes: ["inline", "pip"],
347
+ }),
348
+ );
349
+ expect(bridge.notifyHostContextChanged).toHaveBeenCalledTimes(3);
350
+
162
351
  sandboxBridge.dispose();
163
352
  });
164
353
  it("cancels a queued host context that returns to the delivered value", () => {
@@ -42,12 +42,12 @@ const isSameHostContext = (a: unknown, b: unknown, depth = 0): boolean => {
42
42
  if (Object.is(a, b)) return true;
43
43
  if (depth > 100) return false;
44
44
  if (Array.isArray(a) || Array.isArray(b)) {
45
- return (
46
- Array.isArray(a) &&
47
- Array.isArray(b) &&
48
- a.length === b.length &&
49
- a.every((item, index) => isSameHostContext(item, b[index], depth + 1))
50
- );
45
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length)
46
+ return false;
47
+ for (let i = 0; i < a.length; i++) {
48
+ if (!isSameHostContext(a[i], b[i], depth + 1)) return false;
49
+ }
50
+ return true;
51
51
  }
52
52
  if (!isPlainObject(a) || !isPlainObject(b)) return false;
53
53
  const aKeys = Object.keys(a);
@@ -96,25 +96,33 @@ type LiveSnapshot = {
96
96
  output: unknown;
97
97
  };
98
98
 
99
+ type FrameLifecycle = {
100
+ onInitialized: () => void;
101
+ onSizeChange: (params: { width?: number; height?: number }) => void;
102
+ };
103
+
99
104
  // Proxy each per-call handler through liveRef so the bridge always dispatches
100
105
  // to the latest handler reference (e.g. inline callbacks closing over state).
101
106
  // Capability presence is snapshot at mount: a handler added later requires a
102
107
  // remount (keyed on resource URI) to expose the capability to the widget.
108
+ // allowedTools is the exception: it is never advertised in the ui/initialize
109
+ // response, so it stays a live getter, which means this object must reach the
110
+ // bridge uncopied and the frame passes its lifecycle work in rather than
111
+ // wrapping the result.
103
112
  function buildLiveHandlers(
104
113
  initial: McpAppBridgeHandlers | undefined,
105
114
  liveRef: { readonly current: LiveSnapshot },
115
+ lifecycle: FrameLifecycle,
106
116
  ): McpAppBridgeHandlers {
107
117
  const live = () => liveRef.current.handlers;
108
118
  const has = <K extends keyof McpAppBridgeHandlers>(key: K) =>
109
119
  initial?.[key] !== undefined;
110
120
  const out: McpAppBridgeHandlers = {};
111
- if (has("allowedTools")) {
112
- Object.defineProperty(out, "allowedTools", {
113
- get: () => live()?.allowedTools,
114
- enumerable: true,
115
- configurable: true,
116
- });
117
- }
121
+ Object.defineProperty(out, "allowedTools", {
122
+ get: () => live()?.allowedTools,
123
+ enumerable: true,
124
+ configurable: true,
125
+ });
118
126
  const liveCall = <K extends keyof McpAppBridgeHandlers>(
119
127
  key: K,
120
128
  ): NonNullable<McpAppBridgeHandlers[K]> =>
@@ -134,8 +142,14 @@ function buildLiveHandlers(
134
142
  out.updateModelContext = liveCall("updateModelContext");
135
143
  if (has("requestDisplayMode"))
136
144
  out.requestDisplayMode = liveCall("requestDisplayMode");
137
- out.onSizeChange = (p) => live()?.onSizeChange?.(p);
138
- out.onInitialized = () => live()?.onInitialized?.();
145
+ out.onSizeChange = (p) => {
146
+ lifecycle.onSizeChange(p);
147
+ live()?.onSizeChange?.(p);
148
+ };
149
+ out.onInitialized = () => {
150
+ lifecycle.onInitialized();
151
+ live()?.onInitialized?.();
152
+ };
139
153
  out.onRequestTeardown = (p) => live()?.onRequestTeardown?.(p);
140
154
  out.onLog = (p) => live()?.onLog?.(p);
141
155
  out.onError = (e) => live()?.onError?.(e);
@@ -215,23 +229,18 @@ export function McpAppFrame({
215
229
  }
216
230
  };
217
231
 
218
- const liveHandlers = buildLiveHandlers(current.handlers, liveRef);
219
- const liveOnInitialized = liveHandlers.onInitialized;
220
- const wrappedHandlers: McpAppBridgeHandlers = {
221
- ...liveHandlers,
232
+ const wrappedHandlers = buildLiveHandlers(current.handlers, liveRef, {
222
233
  onInitialized: () => {
223
234
  if (initTimeoutId !== null) {
224
235
  clearTimeout(initTimeoutId);
225
236
  initTimeoutId = null;
226
237
  }
227
238
  flushPending();
228
- liveOnInitialized?.();
229
239
  },
230
240
  onSizeChange: (p) => {
231
241
  if (p.height != null) host.setHeight(p.height);
232
- liveHandlers.onSizeChange?.(p);
233
242
  },
234
- };
243
+ });
235
244
 
236
245
  // Safety net: if the widget never sends notifications/initialized (broken
237
246
  // or non-spec-compliant), flush the queue anyway so the host doesn't
@@ -37,6 +37,104 @@ async function flush() {
37
37
  }
38
38
 
39
39
  describe("createMcpAppBridge", () => {
40
+ it("ignores requests and notifications after disposal", async () => {
41
+ const { frame, captured } = makeFrame();
42
+ const callTool = vi.fn().mockResolvedValue({ ok: true });
43
+ const onInitialized = vi.fn();
44
+ const bridge = createMcpAppBridge({
45
+ frame,
46
+ handlers: { callTool, onInitialized },
47
+ });
48
+
49
+ bridge.dispose();
50
+ expect(() => bridge.dispose()).not.toThrow();
51
+
52
+ deliver(bridge, {
53
+ jsonrpc: "2.0",
54
+ id: 1,
55
+ method: "tools/call",
56
+ params: { name: "search" },
57
+ });
58
+ deliver(bridge, {
59
+ jsonrpc: "2.0",
60
+ method: "notifications/initialized",
61
+ });
62
+ await flush();
63
+
64
+ expect(callTool).not.toHaveBeenCalled();
65
+ expect(onInitialized).not.toHaveBeenCalled();
66
+ expect(captured).toEqual([]);
67
+ });
68
+
69
+ it("does not send a response after disposal", async () => {
70
+ const { frame, captured } = makeFrame();
71
+ let resolveCall!: (value: unknown) => void;
72
+ const callTool = vi.fn(
73
+ () =>
74
+ new Promise((resolve) => {
75
+ resolveCall = resolve;
76
+ }),
77
+ );
78
+ const bridge = createMcpAppBridge({ frame, handlers: { callTool } });
79
+
80
+ deliver(bridge, {
81
+ jsonrpc: "2.0",
82
+ id: 1,
83
+ method: "tools/call",
84
+ params: { name: "search" },
85
+ });
86
+ expect(callTool).toHaveBeenCalledOnce();
87
+
88
+ bridge.dispose();
89
+ resolveCall({ ok: true });
90
+ await flush();
91
+
92
+ expect(captured).toEqual([]);
93
+ });
94
+
95
+ it("does not report an async error after disposal", async () => {
96
+ const { frame, captured } = makeFrame();
97
+ let rejectCall!: (reason?: unknown) => void;
98
+ const callTool = vi.fn(
99
+ () =>
100
+ new Promise((_resolve, reject) => {
101
+ rejectCall = reject;
102
+ }),
103
+ );
104
+ const onError = vi.fn();
105
+ const bridge = createMcpAppBridge({
106
+ frame,
107
+ handlers: { callTool, onError },
108
+ });
109
+
110
+ deliver(bridge, {
111
+ jsonrpc: "2.0",
112
+ id: 1,
113
+ method: "tools/call",
114
+ params: { name: "search" },
115
+ });
116
+ expect(callTool).toHaveBeenCalledOnce();
117
+
118
+ bridge.dispose();
119
+ rejectCall(new Error("tool failed"));
120
+ await flush();
121
+
122
+ expect(onError).not.toHaveBeenCalled();
123
+ expect(captured).toEqual([]);
124
+ });
125
+
126
+ it("does not send host notifications after disposal", () => {
127
+ const { frame, captured } = makeFrame();
128
+ const bridge = createMcpAppBridge({ frame });
129
+
130
+ bridge.dispose();
131
+ bridge.notifyToolInput({ query: "hello" });
132
+ bridge.notifyToolResult({ answer: "world" });
133
+ bridge.notifyHostContextChanged({ theme: "dark" });
134
+
135
+ expect(captured).toEqual([]);
136
+ });
137
+
40
138
  it("responds to ui/initialize with host info, version, and capabilities", async () => {
41
139
  const { frame, captured } = makeFrame();
42
140
  const bridge = createMcpAppBridge({
@@ -90,8 +90,10 @@ export function createMcpAppBridge(
90
90
  hostInfo = DEFAULT_HOST_INFO,
91
91
  hostContext = {},
92
92
  } = opts;
93
+ let disposed = false;
93
94
 
94
95
  const post = (msg: McpAppJsonRpcMessage) => {
96
+ if (disposed) return;
95
97
  frame.sendMessage(msg);
96
98
  };
97
99
 
@@ -125,6 +127,7 @@ export function createMcpAppBridge(
125
127
  };
126
128
 
127
129
  const reportError = (error: Error) => {
130
+ if (disposed) return;
128
131
  invokeUserCallback(
129
132
  "assistant-ui",
130
133
  "MCP App onError",
@@ -435,6 +438,7 @@ export function createMcpAppBridge(
435
438
  // The host applies the cross-origin guard before delegating; this only
436
439
  // validates the JSON-RPC envelope.
437
440
  const onMessage = (event: MessageEvent) => {
441
+ if (disposed) return;
438
442
  if (!isJsonRpcMessage(event.data)) return;
439
443
 
440
444
  const msg = event.data;
@@ -447,7 +451,9 @@ export function createMcpAppBridge(
447
451
 
448
452
  return {
449
453
  onMessage,
450
- dispose: () => {},
454
+ dispose: () => {
455
+ disposed = true;
456
+ },
451
457
  notifyToolInput: (input: unknown) => {
452
458
  post({
453
459
  jsonrpc: "2.0",
@@ -0,0 +1,123 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import {
4
+ act,
5
+ cleanup,
6
+ fireEvent,
7
+ render,
8
+ screen,
9
+ } from "@testing-library/react";
10
+ import { useState } from "react";
11
+ import { afterEach, describe, expect, it, vi } from "vitest";
12
+ import { AssistantRuntimeProvider } from "../../context";
13
+ import { useLocalRuntime } from "../../legacy-runtime/runtime-cores/local/useLocalRuntime";
14
+ import { AssistantModalPrimitiveContent } from "./AssistantModalContent";
15
+ import { AssistantModalPrimitiveRoot } from "./AssistantModalRoot";
16
+ import { AssistantModalPrimitiveTrigger } from "./AssistantModalTrigger";
17
+
18
+ const adapter = {
19
+ async *run() {
20
+ yield { content: [{ type: "text" as const, text: "Hello" }] };
21
+ },
22
+ };
23
+
24
+ const Modal = ({
25
+ controlled = true,
26
+ onOpenChange,
27
+ ...props
28
+ }: AssistantModalPrimitiveRoot.Props & { controlled?: boolean }) => {
29
+ const runtime = useLocalRuntime(adapter);
30
+ const [open, setOpen] = useState(false);
31
+ return (
32
+ <AssistantRuntimeProvider runtime={runtime}>
33
+ <button onClick={() => runtime.thread.append("Hello")}>Start run</button>
34
+ <AssistantModalPrimitiveRoot
35
+ {...(controlled ? { open } : {})}
36
+ onOpenChange={(value) => {
37
+ setOpen(value);
38
+ onOpenChange?.(value);
39
+ }}
40
+ {...props}
41
+ >
42
+ <AssistantModalPrimitiveTrigger>
43
+ Toggle chat
44
+ </AssistantModalPrimitiveTrigger>
45
+ <AssistantModalPrimitiveContent aria-label="Chat">
46
+ Response
47
+ </AssistantModalPrimitiveContent>
48
+ </AssistantModalPrimitiveRoot>
49
+ </AssistantRuntimeProvider>
50
+ );
51
+ };
52
+
53
+ afterEach(cleanup);
54
+
55
+ describe("AssistantModalPrimitiveRoot run start", () => {
56
+ it.each([true, false])(
57
+ "opens on run start and closes through the trigger (controlled: %s)",
58
+ async (controlled) => {
59
+ const onOpenChange = vi.fn();
60
+ render(<Modal controlled={controlled} onOpenChange={onOpenChange} />);
61
+ expect(screen.queryByRole("dialog")).toBeNull();
62
+
63
+ await act(async () => {
64
+ fireEvent.click(screen.getByRole("button", { name: "Start run" }));
65
+ });
66
+ expect(await screen.findByRole("dialog", { name: "Chat" })).toBeDefined();
67
+ expect(onOpenChange).toHaveBeenCalledExactlyOnceWith(true);
68
+
69
+ await act(async () => {
70
+ fireEvent.click(screen.getByRole("button", { name: "Start run" }));
71
+ });
72
+ expect(onOpenChange).toHaveBeenCalledExactlyOnceWith(true);
73
+
74
+ fireEvent.click(screen.getByRole("button", { name: "Toggle chat" }));
75
+ expect(screen.queryByRole("dialog")).toBeNull();
76
+ },
77
+ );
78
+
79
+ it.each([{ open: true }, { controlled: false, defaultOpen: true }])(
80
+ "does not request opening when already open (%o)",
81
+ async (props) => {
82
+ const onOpenChange = vi.fn();
83
+ render(<Modal {...props} onOpenChange={onOpenChange} />);
84
+ expect(screen.getByRole("dialog", { name: "Chat" })).toBeDefined();
85
+
86
+ await act(async () => {
87
+ fireEvent.click(screen.getByRole("button", { name: "Start run" }));
88
+ });
89
+
90
+ expect(onOpenChange).not.toHaveBeenCalled();
91
+ expect(screen.getByRole("dialog", { name: "Chat" })).toBeDefined();
92
+ },
93
+ );
94
+
95
+ it("notifies the current owner without overriding its controlled value", async () => {
96
+ const previous = vi.fn();
97
+ const current = vi.fn();
98
+ const view = render(<Modal open={false} onOpenChange={previous} />);
99
+ view.rerender(<Modal open={false} onOpenChange={current} />);
100
+
101
+ await act(async () => {
102
+ fireEvent.click(screen.getByRole("button", { name: "Start run" }));
103
+ });
104
+
105
+ expect(current).toHaveBeenCalledExactlyOnceWith(true);
106
+ expect(previous).not.toHaveBeenCalled();
107
+ expect(screen.queryByRole("dialog")).toBeNull();
108
+ });
109
+
110
+ it("does not request opening when auto-open is disabled", async () => {
111
+ const onOpenChange = vi.fn();
112
+ render(
113
+ <Modal unstable_openOnRunStart={false} onOpenChange={onOpenChange} />,
114
+ );
115
+
116
+ await act(async () => {
117
+ fireEvent.click(screen.getByRole("button", { name: "Start run" }));
118
+ });
119
+
120
+ expect(onOpenChange).not.toHaveBeenCalled();
121
+ expect(screen.queryByRole("dialog")).toBeNull();
122
+ });
123
+ });