@miragon/mcp-toolkit-ui 2.7.0 → 2.8.1

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 (388) hide show
  1. package/README.md +7 -4
  2. package/dist/app/adapt-data-widget.d.ts +1 -22
  3. package/dist/app/adapt-data-widget.d.ts.map +1 -1
  4. package/dist/app/adapt-data-widget.js +1 -35
  5. package/dist/app/adapt-data-widget.js.map +1 -1
  6. package/dist/app/builder/BuilderBanners.js +1 -1
  7. package/dist/app/builder/BuilderBanners.js.map +1 -1
  8. package/dist/app/builder/CanvasRow.d.ts +4 -2
  9. package/dist/app/builder/CanvasRow.d.ts.map +1 -1
  10. package/dist/app/builder/CanvasRow.js +21 -7
  11. package/dist/app/builder/CanvasRow.js.map +1 -1
  12. package/dist/app/builder/CatalogueSheet.d.ts.map +1 -1
  13. package/dist/app/builder/CatalogueSheet.js +11 -4
  14. package/dist/app/builder/CatalogueSheet.js.map +1 -1
  15. package/dist/app/builder/CellPropsSheet.d.ts +7 -3
  16. package/dist/app/builder/CellPropsSheet.d.ts.map +1 -1
  17. package/dist/app/builder/CellPropsSheet.js +10 -3
  18. package/dist/app/builder/CellPropsSheet.js.map +1 -1
  19. package/dist/app/builder/DiscardDialog.d.ts +18 -0
  20. package/dist/app/builder/DiscardDialog.d.ts.map +1 -0
  21. package/dist/app/builder/DiscardDialog.js +18 -0
  22. package/dist/app/builder/DiscardDialog.js.map +1 -0
  23. package/dist/app/builder/LayoutSurface.d.ts +33 -9
  24. package/dist/app/builder/LayoutSurface.d.ts.map +1 -1
  25. package/dist/app/builder/LayoutSurface.js +74 -23
  26. package/dist/app/builder/LayoutSurface.js.map +1 -1
  27. package/dist/app/builder/PipelineStrip.d.ts +2 -1
  28. package/dist/app/builder/PipelineStrip.d.ts.map +1 -1
  29. package/dist/app/builder/PipelineStrip.js +26 -13
  30. package/dist/app/builder/PipelineStrip.js.map +1 -1
  31. package/dist/app/builder/SaveDialog.d.ts +9 -1
  32. package/dist/app/builder/SaveDialog.d.ts.map +1 -1
  33. package/dist/app/builder/SaveDialog.js +8 -2
  34. package/dist/app/builder/SaveDialog.js.map +1 -1
  35. package/dist/app/builder/Toolbar.d.ts +12 -4
  36. package/dist/app/builder/Toolbar.d.ts.map +1 -1
  37. package/dist/app/builder/Toolbar.js +18 -7
  38. package/dist/app/builder/Toolbar.js.map +1 -1
  39. package/dist/app/builder/Workspace.d.ts +23 -3
  40. package/dist/app/builder/Workspace.d.ts.map +1 -1
  41. package/dist/app/builder/Workspace.js +58 -8
  42. package/dist/app/builder/Workspace.js.map +1 -1
  43. package/dist/app/builder/builder-model.d.ts +143 -1
  44. package/dist/app/builder/builder-model.d.ts.map +1 -1
  45. package/dist/app/builder/builder-model.js +191 -1
  46. package/dist/app/builder/builder-model.js.map +1 -1
  47. package/dist/app/builder/reveal.d.ts +10 -0
  48. package/dist/app/builder/reveal.d.ts.map +1 -0
  49. package/dist/app/builder/reveal.js +10 -0
  50. package/dist/app/builder/reveal.js.map +1 -0
  51. package/dist/app/builder/use-builder-catalogue.d.ts +4 -1
  52. package/dist/app/builder/use-builder-catalogue.d.ts.map +1 -1
  53. package/dist/app/builder/use-builder-catalogue.js +44 -17
  54. package/dist/app/builder/use-builder-catalogue.js.map +1 -1
  55. package/dist/app/builder/use-builder-session.d.ts +30 -0
  56. package/dist/app/builder/use-builder-session.d.ts.map +1 -0
  57. package/dist/app/builder/use-builder-session.js +58 -0
  58. package/dist/app/builder/use-builder-session.js.map +1 -0
  59. package/dist/app/builder/use-display-resolver.d.ts +17 -0
  60. package/dist/app/builder/use-display-resolver.d.ts.map +1 -0
  61. package/dist/app/builder/use-display-resolver.js +16 -0
  62. package/dist/app/builder/use-display-resolver.js.map +1 -0
  63. package/dist/app/index.d.ts +8 -3
  64. package/dist/app/index.d.ts.map +1 -1
  65. package/dist/app/index.js +8 -2
  66. package/dist/app/index.js.map +1 -1
  67. package/dist/app/layout-builder.d.ts +13 -25
  68. package/dist/app/layout-builder.d.ts.map +1 -1
  69. package/dist/app/layout-builder.js +75 -81
  70. package/dist/app/layout-builder.js.map +1 -1
  71. package/dist/app/mcp-app-view-chrome.d.ts +12 -3
  72. package/dist/app/mcp-app-view-chrome.d.ts.map +1 -1
  73. package/dist/app/mcp-app-view-chrome.js +21 -5
  74. package/dist/app/mcp-app-view-chrome.js.map +1 -1
  75. package/dist/app/mcp-app-view-states.d.ts +11 -3
  76. package/dist/app/mcp-app-view-states.d.ts.map +1 -1
  77. package/dist/app/mcp-app-view-states.js +29 -8
  78. package/dist/app/mcp-app-view-states.js.map +1 -1
  79. package/dist/app/mcp-app-view.d.ts +33 -1
  80. package/dist/app/mcp-app-view.d.ts.map +1 -1
  81. package/dist/app/mcp-app-view.js +181 -127
  82. package/dist/app/mcp-app-view.js.map +1 -1
  83. package/dist/app/mcp-toolkit-app.d.ts +6 -1
  84. package/dist/app/mcp-toolkit-app.d.ts.map +1 -1
  85. package/dist/app/mcp-toolkit-app.js +8 -1
  86. package/dist/app/mcp-toolkit-app.js.map +1 -1
  87. package/dist/app/mcp-use-host-bridge.d.ts +15 -6
  88. package/dist/app/mcp-use-host-bridge.d.ts.map +1 -1
  89. package/dist/app/mcp-use-host-bridge.js +51 -22
  90. package/dist/app/mcp-use-host-bridge.js.map +1 -1
  91. package/dist/app/model-context.d.ts +71 -0
  92. package/dist/app/model-context.d.ts.map +1 -0
  93. package/dist/app/model-context.js +101 -0
  94. package/dist/app/model-context.js.map +1 -0
  95. package/dist/app/use-build-mode-focus.d.ts +33 -0
  96. package/dist/app/use-build-mode-focus.d.ts.map +1 -0
  97. package/dist/app/use-build-mode-focus.js +59 -0
  98. package/dist/app/use-build-mode-focus.js.map +1 -0
  99. package/dist/app/use-view-actions.d.ts +31 -0
  100. package/dist/app/use-view-actions.d.ts.map +1 -0
  101. package/dist/app/use-view-actions.js +81 -0
  102. package/dist/app/use-view-actions.js.map +1 -0
  103. package/dist/app/use-view-lifecycle.d.ts +4 -0
  104. package/dist/app/use-view-lifecycle.d.ts.map +1 -1
  105. package/dist/app/use-view-lifecycle.js +2 -0
  106. package/dist/app/use-view-lifecycle.js.map +1 -1
  107. package/dist/app/view-lifecycle.d.ts +80 -4
  108. package/dist/app/view-lifecycle.d.ts.map +1 -1
  109. package/dist/app/view-lifecycle.js +90 -3
  110. package/dist/app/view-lifecycle.js.map +1 -1
  111. package/dist/app/widget-fixture.d.ts +10 -3
  112. package/dist/app/widget-fixture.d.ts.map +1 -1
  113. package/dist/app/widget-fixture.js +21 -8
  114. package/dist/app/widget-fixture.js.map +1 -1
  115. package/dist/app/widget-props-sheet.d.ts +3 -1
  116. package/dist/app/widget-props-sheet.d.ts.map +1 -1
  117. package/dist/app/widget-props-sheet.js +22 -10
  118. package/dist/app/widget-props-sheet.js.map +1 -1
  119. package/dist/app/widget-renderer.js +1 -1
  120. package/dist/app/widget-renderer.js.map +1 -1
  121. package/dist/components/DrillButton.d.ts +14 -6
  122. package/dist/components/DrillButton.d.ts.map +1 -1
  123. package/dist/components/DrillButton.js +14 -7
  124. package/dist/components/DrillButton.js.map +1 -1
  125. package/dist/components/EmptyState.d.ts.map +1 -1
  126. package/dist/components/EmptyState.js +4 -2
  127. package/dist/components/EmptyState.js.map +1 -1
  128. package/dist/components/ErrorState.d.ts +5 -2
  129. package/dist/components/ErrorState.d.ts.map +1 -1
  130. package/dist/components/ErrorState.js +25 -8
  131. package/dist/components/ErrorState.js.map +1 -1
  132. package/dist/components/FilterBar.d.ts +15 -4
  133. package/dist/components/FilterBar.d.ts.map +1 -1
  134. package/dist/components/FilterBar.js +11 -6
  135. package/dist/components/FilterBar.js.map +1 -1
  136. package/dist/components/KpiGrid.d.ts +66 -21
  137. package/dist/components/KpiGrid.d.ts.map +1 -1
  138. package/dist/components/KpiGrid.js +101 -34
  139. package/dist/components/KpiGrid.js.map +1 -1
  140. package/dist/components/ListFooter.d.ts +31 -4
  141. package/dist/components/ListFooter.d.ts.map +1 -1
  142. package/dist/components/ListFooter.js +30 -11
  143. package/dist/components/ListFooter.js.map +1 -1
  144. package/dist/components/PagedListFooter.d.ts +57 -0
  145. package/dist/components/PagedListFooter.d.ts.map +1 -0
  146. package/dist/components/PagedListFooter.js +27 -0
  147. package/dist/components/PagedListFooter.js.map +1 -0
  148. package/dist/components/Pills.d.ts +27 -7
  149. package/dist/components/Pills.d.ts.map +1 -1
  150. package/dist/components/Pills.js +21 -6
  151. package/dist/components/Pills.js.map +1 -1
  152. package/dist/components/SectionHeading.d.ts +3 -1
  153. package/dist/components/SectionHeading.d.ts.map +1 -1
  154. package/dist/components/SectionHeading.js +6 -2
  155. package/dist/components/SectionHeading.js.map +1 -1
  156. package/dist/components/Toast.d.ts +9 -3
  157. package/dist/components/Toast.d.ts.map +1 -1
  158. package/dist/components/Toast.js +31 -16
  159. package/dist/components/Toast.js.map +1 -1
  160. package/dist/components/WidgetHeader.d.ts +34 -10
  161. package/dist/components/WidgetHeader.d.ts.map +1 -1
  162. package/dist/components/WidgetHeader.js +11 -6
  163. package/dist/components/WidgetHeader.js.map +1 -1
  164. package/dist/hooks/index.d.ts +4 -4
  165. package/dist/hooks/index.d.ts.map +1 -1
  166. package/dist/hooks/index.js +1 -1
  167. package/dist/hooks/index.js.map +1 -1
  168. package/dist/hooks/tool-result-recovery-model.d.ts +155 -0
  169. package/dist/hooks/tool-result-recovery-model.d.ts.map +1 -0
  170. package/dist/hooks/tool-result-recovery-model.js +273 -0
  171. package/dist/hooks/tool-result-recovery-model.js.map +1 -0
  172. package/dist/hooks/use-tool-query.d.ts +56 -14
  173. package/dist/hooks/use-tool-query.d.ts.map +1 -1
  174. package/dist/hooks/use-tool-query.js +102 -25
  175. package/dist/hooks/use-tool-query.js.map +1 -1
  176. package/dist/hooks/use-tool-result-recovery.d.ts +47 -52
  177. package/dist/hooks/use-tool-result-recovery.d.ts.map +1 -1
  178. package/dist/hooks/use-tool-result-recovery.js +58 -120
  179. package/dist/hooks/use-tool-result-recovery.js.map +1 -1
  180. package/dist/hooks/use-view-data.d.ts +52 -10
  181. package/dist/hooks/use-view-data.d.ts.map +1 -1
  182. package/dist/hooks/use-view-data.js +81 -16
  183. package/dist/hooks/use-view-data.js.map +1 -1
  184. package/dist/hooks/use-view-tool-query.d.ts +12 -7
  185. package/dist/hooks/use-view-tool-query.d.ts.map +1 -1
  186. package/dist/hooks/use-view-tool-query.js +15 -14
  187. package/dist/hooks/use-view-tool-query.js.map +1 -1
  188. package/dist/index.d.ts +17 -10
  189. package/dist/index.d.ts.map +1 -1
  190. package/dist/index.js +18 -6
  191. package/dist/index.js.map +1 -1
  192. package/dist/lib/focus-return.d.ts +61 -0
  193. package/dist/lib/focus-return.d.ts.map +1 -0
  194. package/dist/lib/focus-return.js +46 -0
  195. package/dist/lib/focus-return.js.map +1 -0
  196. package/dist/lib/parse-tool-result.d.ts +23 -1
  197. package/dist/lib/parse-tool-result.d.ts.map +1 -1
  198. package/dist/lib/parse-tool-result.js +38 -2
  199. package/dist/lib/parse-tool-result.js.map +1 -1
  200. package/dist/lib/toast-model.d.ts +41 -1
  201. package/dist/lib/toast-model.d.ts.map +1 -1
  202. package/dist/lib/toast-model.js +67 -1
  203. package/dist/lib/toast-model.js.map +1 -1
  204. package/dist/lib/tone-utils.d.ts +58 -6
  205. package/dist/lib/tone-utils.d.ts.map +1 -1
  206. package/dist/lib/tone-utils.js +66 -4
  207. package/dist/lib/tone-utils.js.map +1 -1
  208. package/dist/lib/tool-query.d.ts +107 -0
  209. package/dist/lib/tool-query.d.ts.map +1 -0
  210. package/dist/lib/tool-query.js +182 -0
  211. package/dist/lib/tool-query.js.map +1 -0
  212. package/dist/lib/tool-result-observer.d.ts +108 -0
  213. package/dist/lib/tool-result-observer.d.ts.map +1 -0
  214. package/dist/lib/tool-result-observer.js +158 -0
  215. package/dist/lib/tool-result-observer.js.map +1 -0
  216. package/dist/lib/typography.d.ts +37 -0
  217. package/dist/lib/typography.d.ts.map +1 -0
  218. package/dist/lib/typography.js +41 -0
  219. package/dist/lib/typography.js.map +1 -0
  220. package/dist/lib/utils.d.ts.map +1 -1
  221. package/dist/lib/utils.js +11 -1
  222. package/dist/lib/utils.js.map +1 -1
  223. package/dist/locale/index.d.ts +1 -1
  224. package/dist/locale/index.d.ts.map +1 -1
  225. package/dist/locale/message-types.d.ts +117 -4
  226. package/dist/locale/message-types.d.ts.map +1 -1
  227. package/dist/locale/messages-de.d.ts.map +1 -1
  228. package/dist/locale/messages-de.js +44 -10
  229. package/dist/locale/messages-de.js.map +1 -1
  230. package/dist/locale/messages-en.d.ts.map +1 -1
  231. package/dist/locale/messages-en.js +44 -10
  232. package/dist/locale/messages-en.js.map +1 -1
  233. package/dist/locale/toolkit-messages.d.ts +19 -1
  234. package/dist/locale/toolkit-messages.d.ts.map +1 -1
  235. package/dist/locale/toolkit-messages.js +54 -1
  236. package/dist/locale/toolkit-messages.js.map +1 -1
  237. package/dist/primitives/alert.d.ts +1 -1
  238. package/dist/primitives/alert.d.ts.map +1 -1
  239. package/dist/primitives/alert.js +2 -0
  240. package/dist/primitives/alert.js.map +1 -1
  241. package/dist/primitives/badge.d.ts +1 -1
  242. package/dist/primitives/badge.d.ts.map +1 -1
  243. package/dist/primitives/badge.js +9 -4
  244. package/dist/primitives/badge.js.map +1 -1
  245. package/dist/primitives/button.d.ts +1 -1
  246. package/dist/primitives/button.js +1 -1
  247. package/dist/primitives/button.js.map +1 -1
  248. package/dist/primitives/dialog.d.ts +1 -1
  249. package/dist/primitives/dialog.d.ts.map +1 -1
  250. package/dist/primitives/dialog.js +10 -3
  251. package/dist/primitives/dialog.js.map +1 -1
  252. package/dist/primitives/select.js +1 -1
  253. package/dist/primitives/select.js.map +1 -1
  254. package/dist/primitives/sheet.d.ts +1 -1
  255. package/dist/primitives/sheet.d.ts.map +1 -1
  256. package/dist/primitives/sheet.js +6 -2
  257. package/dist/primitives/sheet.js.map +1 -1
  258. package/dist/providers/query-provider.d.ts +32 -1
  259. package/dist/providers/query-provider.d.ts.map +1 -1
  260. package/dist/providers/query-provider.js +53 -4
  261. package/dist/providers/query-provider.js.map +1 -1
  262. package/dist/render-cases-primitives.js +1 -1
  263. package/dist/render-cases-primitives.js.map +1 -1
  264. package/dist/render-cases.d.ts.map +1 -1
  265. package/dist/render-cases.js +30 -6
  266. package/dist/render-cases.js.map +1 -1
  267. package/dist/render-effects.d.ts +42 -0
  268. package/dist/render-effects.d.ts.map +1 -0
  269. package/dist/render-effects.js +81 -0
  270. package/dist/render-effects.js.map +1 -0
  271. package/dist/theme/create-theme.d.ts +19 -1
  272. package/dist/theme/create-theme.d.ts.map +1 -1
  273. package/dist/theme/create-theme.js +15 -0
  274. package/dist/theme/create-theme.js.map +1 -1
  275. package/package.json +5 -4
  276. package/src/app/adapt-data-widget.tsx +2 -42
  277. package/src/app/builder/BuilderBanners.tsx +1 -1
  278. package/src/app/builder/CanvasRow.tsx +63 -17
  279. package/src/app/builder/CatalogueSheet.tsx +50 -29
  280. package/src/app/builder/CellPropsSheet.tsx +17 -2
  281. package/src/app/builder/DiscardDialog.tsx +59 -0
  282. package/src/app/builder/LayoutSurface.tsx +206 -66
  283. package/src/app/builder/PipelineStrip.tsx +253 -153
  284. package/src/app/builder/SaveDialog.tsx +14 -2
  285. package/src/app/builder/Toolbar.tsx +144 -59
  286. package/src/app/builder/Workspace.tsx +226 -58
  287. package/src/app/builder/builder-model.ts +341 -2
  288. package/src/app/builder/builder-model.ux.test.ts +456 -0
  289. package/src/app/builder/builder-ux.test.tsx +844 -0
  290. package/src/app/builder/reveal.ts +10 -0
  291. package/src/app/builder/use-builder-catalogue.ts +56 -19
  292. package/src/app/builder/use-builder-session.ts +68 -0
  293. package/src/app/builder/use-display-resolver.ts +24 -0
  294. package/src/app/index.ts +12 -2
  295. package/src/app/layout-builder.tsx +178 -191
  296. package/src/app/mcp-app-view-chrome.test.tsx +141 -1
  297. package/src/app/mcp-app-view-chrome.tsx +56 -6
  298. package/src/app/mcp-app-view-states.tsx +51 -11
  299. package/src/app/mcp-app-view.effects.test.tsx +292 -0
  300. package/src/app/mcp-app-view.test.tsx +222 -2
  301. package/src/app/mcp-app-view.tsx +267 -117
  302. package/src/app/mcp-toolkit-app.test.tsx +40 -0
  303. package/src/app/mcp-toolkit-app.tsx +8 -1
  304. package/src/app/mcp-use-host-bridge.test.tsx +180 -0
  305. package/src/app/mcp-use-host-bridge.tsx +67 -24
  306. package/src/app/model-context.test.tsx +116 -0
  307. package/src/app/model-context.tsx +125 -0
  308. package/src/app/use-build-mode-focus.ts +62 -0
  309. package/src/app/use-view-actions.ts +89 -0
  310. package/src/app/use-view-lifecycle.ts +6 -0
  311. package/src/app/view-lifecycle.test.ts +216 -0
  312. package/src/app/view-lifecycle.ts +165 -5
  313. package/src/app/widget-fixture.effects.test.tsx +51 -0
  314. package/src/app/widget-fixture.render.test.tsx +81 -0
  315. package/src/app/widget-fixture.tsx +34 -11
  316. package/src/app/widget-props-sheet.tsx +57 -16
  317. package/src/app/widget-renderer.tsx +1 -1
  318. package/src/components/DrillButton.test.tsx +48 -0
  319. package/src/components/DrillButton.tsx +26 -11
  320. package/src/components/EmptyState.tsx +6 -4
  321. package/src/components/ErrorState.tsx +31 -9
  322. package/src/components/FilterBar.test.tsx +82 -0
  323. package/src/components/FilterBar.tsx +52 -26
  324. package/src/components/KpiGrid.test.tsx +279 -0
  325. package/src/components/KpiGrid.tsx +297 -90
  326. package/src/components/ListFooter.test.tsx +116 -0
  327. package/src/components/ListFooter.tsx +102 -15
  328. package/src/components/PagedListFooter.test.tsx +140 -0
  329. package/src/components/PagedListFooter.tsx +107 -0
  330. package/src/components/Pills.test.tsx +77 -0
  331. package/src/components/Pills.tsx +63 -10
  332. package/src/components/SectionHeading.tsx +9 -3
  333. package/src/components/Toast.tsx +37 -19
  334. package/src/components/WidgetHeader.test.tsx +78 -0
  335. package/src/components/WidgetHeader.tsx +63 -16
  336. package/src/components/feedback-states.test.tsx +98 -9
  337. package/src/components/tone-figures.test.tsx +8 -4
  338. package/src/hooks/index.ts +6 -3
  339. package/src/hooks/tool-result-recovery-model.test.ts +744 -0
  340. package/src/hooks/tool-result-recovery-model.ts +418 -0
  341. package/src/hooks/use-tool-query.effects.test.tsx +205 -0
  342. package/src/hooks/use-tool-query.ts +156 -33
  343. package/src/hooks/use-tool-result-recovery.effects.test.tsx +141 -0
  344. package/src/hooks/use-tool-result-recovery.test.ts +3 -4
  345. package/src/hooks/use-tool-result-recovery.ts +84 -142
  346. package/src/hooks/use-view-data.test.tsx +118 -9
  347. package/src/hooks/use-view-data.ts +128 -18
  348. package/src/hooks/use-view-tool-query.ts +25 -19
  349. package/src/i18n-coverage.test.tsx +104 -2
  350. package/src/index.ts +48 -11
  351. package/src/lib/focus-return.test.ts +288 -0
  352. package/src/lib/focus-return.ts +96 -0
  353. package/src/lib/parse-tool-result.test.ts +46 -1
  354. package/src/lib/parse-tool-result.ts +40 -2
  355. package/src/lib/toast-model.test.ts +166 -1
  356. package/src/lib/toast-model.ts +104 -2
  357. package/src/lib/tone-utils.test.ts +86 -10
  358. package/src/lib/tone-utils.ts +89 -8
  359. package/src/lib/tool-query.test.ts +569 -0
  360. package/src/lib/tool-query.ts +253 -0
  361. package/src/lib/tool-result-observer.test.ts +296 -0
  362. package/src/lib/tool-result-observer.ts +211 -0
  363. package/src/lib/typography.test.ts +45 -0
  364. package/src/lib/typography.ts +47 -0
  365. package/src/lib/utils.ts +12 -1
  366. package/src/locale/index.ts +1 -0
  367. package/src/locale/message-types.ts +118 -2
  368. package/src/locale/messages-de.ts +49 -10
  369. package/src/locale/messages-en.ts +49 -10
  370. package/src/locale/toolkit-messages.test.ts +133 -0
  371. package/src/locale/toolkit-messages.ts +63 -2
  372. package/src/primitives/alert.tsx +3 -0
  373. package/src/primitives/badge.tsx +9 -4
  374. package/src/primitives/button.tsx +1 -1
  375. package/src/primitives/dialog.tsx +10 -1
  376. package/src/primitives/select.tsx +1 -1
  377. package/src/primitives/sheet.tsx +7 -0
  378. package/src/providers/query-provider.test.tsx +154 -0
  379. package/src/providers/query-provider.tsx +66 -5
  380. package/src/render-cases-primitives.tsx +1 -1
  381. package/src/render-cases.tsx +38 -6
  382. package/src/render-effects.tsx +96 -0
  383. package/src/theme/cd-tokens.test.ts +3 -1
  384. package/src/theme/create-theme.test.ts +10 -0
  385. package/src/theme/create-theme.ts +28 -0
  386. package/src/theme/theme-css.test.ts +51 -0
  387. package/src/theme.css +31 -1
  388. package/ui-catalog.json +448 -41
@@ -0,0 +1,418 @@
1
+ /**
2
+ * The stripped-result recovery of `McpAppView` as plain logic (toolkit#176):
3
+ * WHEN the originating tool may be re-executed ({@link decideRecovery}) and a
4
+ * framework-free controller that runs that decision against the transport
5
+ * observer, a fallback grace timer and a single-flight call
6
+ * ({@link createRecoveryController}). `useToolResultRecovery` only binds the
7
+ * controller to React, so the scenarios here (keep / strip / slow / late
8
+ * observer) are the hook's behaviour.
9
+ *
10
+ * The rule, in order:
11
+ *
12
+ * 1. Never recover a call that was cancelled or failed: the view renders that
13
+ * state (an `isError` is an error, K14).
14
+ * 2. Recover when the host delivered a result the view cannot render: the
15
+ * latch fired with an invalid payload, or the observer saw the stripped,
16
+ * content-only result claude.ai sends. The re-run uses the COMPLETE
17
+ * arguments of the call (N21), never a partial stream.
18
+ * 3. Otherwise wait. A slow tool on a compliant host is simply still running
19
+ * (K13): the observer, listening since the handshake, would have seen its
20
+ * result. Only when it cannot vouch for that (no observer, or installed
21
+ * after the handshake) does the 2.x grace timer still infer a stripped
22
+ * result, and only once the call's arguments arrived.
23
+ * 4. A needed recovery runs only for a tool the policy allows
24
+ * ({@link RecoveryPolicy}: by default never one the host reports as
25
+ * writing) and only when the host names the tool; else it is `blocked`,
26
+ * which the view renders as an error whose retry is the user's own call.
27
+ */
28
+ import { parseToolResult } from "../lib/parse-tool-result.js"
29
+ import type { ToolObservation, ToolResultObserver } from "../lib/tool-result-observer.js"
30
+
31
+ /**
32
+ * What the host's tool annotations say about re-running the originating
33
+ * tool: `readOnlyHint: true` is `"read-only"`, `readOnlyHint: false` or
34
+ * `destructiveHint: true` is `"writes"`, anything else (no tool definition,
35
+ * no annotations, no read-only hint) is `"unknown"`.
36
+ */
37
+ export type ToolSafety = "read-only" | "writes" | "unknown"
38
+
39
+ /**
40
+ * Which tools the view may re-execute on its own to recover a stripped result.
41
+ *
42
+ * - `"auto"` (default): any tool the host does not report as writing — a
43
+ * view tool is a read by the toolkit's render contract, so a missing hint
44
+ * counts as one.
45
+ * - `"read-only"`: only a tool the host reports with `readOnlyHint: true`.
46
+ * - `"never"`: never; a stripped result shows the error state, whose retry
47
+ * runs the tool on the user's click.
48
+ */
49
+ export type RecoveryPolicy = "auto" | "read-only" | "never"
50
+
51
+ /** Why a needed recovery did not run: the policy forbids it, or the host names no tool. */
52
+ export type RecoveryBlockReason = "policy" | "no-tool"
53
+
54
+ function isRecord(value: unknown): value is Record<string, unknown> {
55
+ return typeof value === "object" && value !== null && !Array.isArray(value)
56
+ }
57
+
58
+ /** Read {@link ToolSafety} from `hostContext.toolInfo.tool.annotations` (SEP-1865). */
59
+ export function resolveToolSafety(hostContext: unknown): ToolSafety {
60
+ if (!isRecord(hostContext) || !isRecord(hostContext.toolInfo)) return "unknown"
61
+ const tool = hostContext.toolInfo.tool
62
+ const annotations = isRecord(tool) ? tool.annotations : undefined
63
+ if (!isRecord(annotations)) return "unknown"
64
+ if (annotations.destructiveHint === true || annotations.readOnlyHint === false) return "writes"
65
+ return annotations.readOnlyHint === true ? "read-only" : "unknown"
66
+ }
67
+
68
+ /** Whether `policy` lets the view re-execute a tool of this `safety` on its own. */
69
+ export function policyAllows(policy: RecoveryPolicy, safety: ToolSafety): boolean {
70
+ if (policy === "never") return false
71
+ if (policy === "read-only") return safety === "read-only"
72
+ return safety !== "writes"
73
+ }
74
+
75
+ export interface RecoveryDecisionInput {
76
+ /** The latch fired (`useToolContext().status !== "pending"`). */
77
+ resultReady: boolean
78
+ /** The latched payload is renderable (a conforming host delivered it). */
79
+ payloadValid: boolean
80
+ /** The host cancelled the call. */
81
+ cancelled: boolean
82
+ /** The call latched a tool error (an `isError` result or a failed call). */
83
+ errored: boolean
84
+ /** What the transport observer saw; `null` when none listens. */
85
+ observation: ToolObservation | null
86
+ /** The call's arguments arrived (any tool-input, partial or complete). */
87
+ hasInput: boolean
88
+ /** The originating tool (`hostContext.toolInfo`), when the host names it. */
89
+ toolName: string | undefined
90
+ safety: ToolSafety
91
+ policy: RecoveryPolicy
92
+ /** The fallback grace period passed for the current arguments. */
93
+ graceElapsed: boolean
94
+ }
95
+
96
+ export type RecoveryDecision =
97
+ /** Delivered, failed or cancelled: nothing to recover. */
98
+ | { action: "none" }
99
+ /** The result is not in yet; `armGrace` starts the fallback timer (observer cannot vouch). */
100
+ | { action: "wait"; armGrace: boolean }
101
+ /** Re-execute the originating tool now. */
102
+ | { action: "recover" }
103
+ /** A recovery is needed but must not run. */
104
+ | { action: "blocked"; reason: RecoveryBlockReason }
105
+
106
+ const NONE: RecoveryDecision = { action: "none" }
107
+ const WAIT: RecoveryDecision = { action: "wait", armGrace: false }
108
+ const WAIT_ARMED: RecoveryDecision = { action: "wait", armGrace: true }
109
+ const RECOVER: RecoveryDecision = { action: "recover" }
110
+
111
+ /** Rules 1–3: does the result need recovering, or is it delivered / still on its way? */
112
+ function recoveryNeed(input: RecoveryDecisionInput): RecoveryDecision | "needed" {
113
+ if (input.resultReady) return input.payloadValid ? NONE : "needed"
114
+ const observed = input.observation?.result ?? "none"
115
+ if (observed === "stripped") return "needed"
116
+ // A structured result or an error: the latch is about to follow.
117
+ if (observed !== "none") return NONE
118
+ // Listening since the handshake: nothing arrived because nothing was sent.
119
+ if (input.observation?.sawHandshake) return WAIT
120
+ if (input.toolName === undefined || !input.hasInput) return WAIT
121
+ return input.graceElapsed ? "needed" : WAIT_ARMED
122
+ }
123
+
124
+ /** The recovery rule (see the module comment). Pure: every input is explicit. */
125
+ export function decideRecovery(input: RecoveryDecisionInput): RecoveryDecision {
126
+ if (input.cancelled || input.errored || input.observation?.cancelled) return NONE
127
+ const need = recoveryNeed(input)
128
+ if (need !== "needed") return need
129
+ if (input.toolName === undefined) return { action: "blocked", reason: "no-tool" }
130
+ if (!policyAllows(input.policy, input.safety)) return { action: "blocked", reason: "policy" }
131
+ return RECOVER
132
+ }
133
+
134
+ /**
135
+ * The arguments a re-execution runs with: the complete tool-input the
136
+ * observer saw, else the view runtime's latest input, else none.
137
+ */
138
+ export function recoveryArgs(
139
+ observation: ToolObservation | null,
140
+ toolInput: Record<string, unknown> | null | undefined,
141
+ ): Record<string, unknown> {
142
+ return observation?.completeInput ?? toolInput ?? {}
143
+ }
144
+
145
+ /**
146
+ * Content identity of a JSON value, independent of key order and object
147
+ * identity: a re-delivered equal input is the same input (N21).
148
+ */
149
+ export function stableKey(value: unknown): string {
150
+ return (
151
+ JSON.stringify(value, (_key, v: unknown) =>
152
+ isRecord(v)
153
+ ? Object.fromEntries(
154
+ Object.keys(v)
155
+ .sort()
156
+ .map((k) => [k, v[k]]),
157
+ )
158
+ : v,
159
+ ) ?? "undefined"
160
+ )
161
+ }
162
+
163
+ /**
164
+ * Decodes a re-executed tool result and validates it: the payload, or `null`
165
+ * for an error result, an empty one or one the validator refuses.
166
+ */
167
+ export function decodeRecoveredResult<T>(
168
+ result: unknown,
169
+ isValid: (value: unknown) => boolean,
170
+ ): T | null {
171
+ let decoded: unknown
172
+ try {
173
+ decoded = parseToolResult<unknown>(result)
174
+ } catch {
175
+ return null
176
+ }
177
+ return decoded !== null && isValid(decoded) ? (decoded as T) : null
178
+ }
179
+
180
+ export interface ToolResultRecoveryState<T> {
181
+ /** The recovered payload, `null` until the re-execution resolved (or when it failed). */
182
+ data: T | null
183
+ /** The single recovery attempt failed — the view shows its error state, no retry loop. */
184
+ failed: boolean
185
+ /**
186
+ * A recovery was needed but did not run: the policy forbids re-executing
187
+ * this tool, or the host names no tool. `null` otherwise.
188
+ */
189
+ blocked: RecoveryBlockReason | null
190
+ /** When the recovered payload arrived (epoch ms) — its data is live as of then (N23). */
191
+ recoveredAt: number | null
192
+ /** The arguments the recovered payload was computed for. */
193
+ args: Record<string, unknown> | null
194
+ }
195
+
196
+ export const RECOVERY_IDLE: ToolResultRecoveryState<never> = Object.freeze({
197
+ data: null,
198
+ failed: false,
199
+ blocked: null,
200
+ recoveredAt: null,
201
+ args: null,
202
+ })
203
+
204
+ /** What the view knows about the rendering call on each render. */
205
+ export interface RecoveryInputs {
206
+ resultReady: boolean
207
+ cancelled: boolean
208
+ errored: boolean
209
+ /** The payload the host delivered (`useToolContext().toolOutput`). */
210
+ props: unknown
211
+ isValid: (value: unknown) => boolean
212
+ toolInput: Record<string, unknown> | null | undefined
213
+ /** Raw host context; `toolInfo` names the originating tool and its annotations. */
214
+ hostContext: Record<string, unknown> | null | undefined
215
+ callTool: (name: string, args: Record<string, unknown>) => Promise<unknown>
216
+ policy: RecoveryPolicy
217
+ /** The fallback grace period (observer cannot vouch). */
218
+ graceMs: number
219
+ /** Resolves the originating tool name from the host context. */
220
+ resolveToolName: (hostContext: unknown) => string | undefined
221
+ }
222
+
223
+ export interface RecoveryController<T> {
224
+ subscribe: (listener: () => void) => () => void
225
+ getState: () => ToolResultRecoveryState<T>
226
+ /** Feed the latest inputs (every commit) and act on the decision. */
227
+ update: (inputs: RecoveryInputs) => void
228
+ /** Listen to the observer (mount). */
229
+ start: () => void
230
+ /** Stop listening and drop the grace timer; an in-flight call still lands (StrictMode re-runs). */
231
+ stop: () => void
232
+ }
233
+
234
+ export interface RecoveryControllerOptions {
235
+ /** The transport observer; `null` when none listens (the grace timer decides alone). */
236
+ observer: ToolResultObserver | null
237
+ /** Clock for {@link ToolResultRecoveryState.recoveredAt}. Default `Date.now`. */
238
+ now?: () => number
239
+ }
240
+
241
+ interface Flight {
242
+ key: string
243
+ promise: Promise<unknown>
244
+ }
245
+
246
+ /** One evaluation of the inputs: the decision plus the run it concerns. */
247
+ interface Evaluation {
248
+ decision: RecoveryDecision
249
+ toolName: string | undefined
250
+ args: Record<string, unknown>
251
+ /** Content identity of `args` (the grace timer's key). */
252
+ argsKey: string
253
+ /** Identity of the run: the tool and its arguments (the single flight's key). */
254
+ runKey: string
255
+ }
256
+
257
+ /** Run {@link decideRecovery} on the controller's current knowledge. */
258
+ export function evaluateRecovery(
259
+ inputs: RecoveryInputs,
260
+ observation: ToolObservation | null,
261
+ grace: { key: string | null; elapsed: boolean },
262
+ ): Evaluation {
263
+ const toolName = inputs.resolveToolName(inputs.hostContext)
264
+ const args = recoveryArgs(observation, inputs.toolInput)
265
+ const argsKey = stableKey(args)
266
+ const decision = decideRecovery({
267
+ resultReady: inputs.resultReady,
268
+ payloadValid: inputs.isValid(inputs.props),
269
+ cancelled: inputs.cancelled,
270
+ errored: inputs.errored,
271
+ observation,
272
+ hasInput: inputs.toolInput != null || observation?.completeInput !== undefined,
273
+ toolName,
274
+ safety: resolveToolSafety(inputs.hostContext),
275
+ policy: inputs.policy,
276
+ graceElapsed: grace.elapsed && grace.key === argsKey,
277
+ })
278
+ return { decision, toolName, args, argsKey, runKey: `${toolName ?? ""}\u0000${argsKey}` }
279
+ }
280
+
281
+ /** Re-execute the tool and decode its answer; `null` for anything unusable. */
282
+ async function rerun<T>(
283
+ callTool: RecoveryInputs["callTool"],
284
+ isValid: RecoveryInputs["isValid"],
285
+ toolName: string,
286
+ args: Record<string, unknown>,
287
+ ): Promise<T | null> {
288
+ try {
289
+ const data = decodeRecoveredResult<T>(await callTool(toolName, args), isValid)
290
+ if (data === null) {
291
+ console.warn(
292
+ `[mcp-toolkit] tool-result recovery: re-executing "${toolName}" returned no usable payload`,
293
+ )
294
+ }
295
+ return data
296
+ } catch (err) {
297
+ console.warn(`[mcp-toolkit] tool-result recovery for "${toolName}" failed:`, err)
298
+ return null
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Runs {@link decideRecovery} for the view: re-evaluates on every input
304
+ * update and observer change, arms the fallback grace timer per argument set,
305
+ * and makes at most one call per run (single flight, keyed by the tool and
306
+ * its arguments; a newer run drops an older call's outcome).
307
+ */
308
+ export function createRecoveryController<T>(
309
+ options: RecoveryControllerOptions,
310
+ ): RecoveryController<T> {
311
+ const { observer } = options
312
+ const now = options.now ?? Date.now
313
+ const listeners = new Set<() => void>()
314
+ let state: ToolResultRecoveryState<T> = RECOVERY_IDLE
315
+ let inputs: RecoveryInputs | null = null
316
+ let unsubscribe: (() => void) | null = null
317
+ let flight: Flight | null = null
318
+ // The fallback grace timer: armed per argument set, elapsed once it fired.
319
+ const grace: { key: string | null; elapsed: boolean } = { key: null, elapsed: false }
320
+ let graceTimer: ReturnType<typeof setTimeout> | null = null
321
+
322
+ const setState = (next: ToolResultRecoveryState<T>) => {
323
+ if (next === state) return
324
+ state = next
325
+ for (const listener of [...listeners]) listener()
326
+ }
327
+
328
+ const clearGraceTimer = () => {
329
+ if (graceTimer !== null) clearTimeout(graceTimer)
330
+ graceTimer = null
331
+ }
332
+
333
+ const armGrace = (key: string, ms: number) => {
334
+ if (grace.key === key && (grace.elapsed || graceTimer !== null)) return
335
+ clearGraceTimer()
336
+ grace.key = key
337
+ grace.elapsed = false
338
+ graceTimer = setTimeout(() => {
339
+ graceTimer = null
340
+ grace.elapsed = true
341
+ evaluate()
342
+ }, ms)
343
+ }
344
+
345
+ const launch = (current: RecoveryInputs, run: Evaluation, toolName: string) => {
346
+ const own: Flight = {
347
+ key: run.runKey,
348
+ promise: rerun<T>(current.callTool, current.isValid, toolName, run.args),
349
+ }
350
+ flight = own
351
+ void own.promise.then((data) => {
352
+ // A newer run superseded this call: its outcome is stale.
353
+ if (flight !== own) return
354
+ setState(
355
+ data === null
356
+ ? { ...RECOVERY_IDLE, failed: true, args: run.args }
357
+ : { data: data as T, failed: false, blocked: null, recoveredAt: now(), args: run.args },
358
+ )
359
+ })
360
+ }
361
+
362
+ /** Act on one evaluation: drop a superseded attempt, launch, block or settle. */
363
+ const apply = (current: RecoveryInputs, run: Evaluation) => {
364
+ const { decision } = run
365
+ // A new run (the need went away, or new arguments) drops the previous
366
+ // attempt and its outcome.
367
+ if (flight && (decision.action !== "recover" || flight.key !== run.runKey)) {
368
+ flight = null
369
+ setState(RECOVERY_IDLE)
370
+ }
371
+ if (decision.action === "blocked") {
372
+ if (state.blocked !== decision.reason) {
373
+ setState({ ...RECOVERY_IDLE, blocked: decision.reason })
374
+ }
375
+ return
376
+ }
377
+ // A lifted block (the tool got named, the policy changed) is gone at once,
378
+ // also while the re-run it allows is still in flight.
379
+ if (state.blocked !== null) setState(RECOVERY_IDLE)
380
+ if (decision.action === "recover" && !flight && run.toolName !== undefined) {
381
+ launch(current, run, run.toolName)
382
+ }
383
+ }
384
+
385
+ function evaluate() {
386
+ if (!inputs) return
387
+ const run = evaluateRecovery(inputs, observer?.getSnapshot() ?? null, grace)
388
+ if (run.decision.action === "wait" && run.decision.armGrace)
389
+ armGrace(run.argsKey, inputs.graceMs)
390
+ else clearGraceTimer()
391
+ apply(inputs, run)
392
+ }
393
+
394
+ return {
395
+ subscribe: (listener) => {
396
+ listeners.add(listener)
397
+ return () => {
398
+ listeners.delete(listener)
399
+ }
400
+ },
401
+ getState: () => state,
402
+ update: (next) => {
403
+ inputs = next
404
+ evaluate()
405
+ },
406
+ start: () => {
407
+ if (!unsubscribe && observer) unsubscribe = observer.subscribe(evaluate)
408
+ evaluate()
409
+ },
410
+ stop: () => {
411
+ unsubscribe?.()
412
+ unsubscribe = null
413
+ // A stopped timer restarts in full on the next start (armGrace re-arms
414
+ // a key whose timer is gone); an elapsed grace stays elapsed.
415
+ clearGraceTimer()
416
+ },
417
+ }
418
+ }
@@ -0,0 +1,205 @@
1
+ // @vitest-environment happy-dom
2
+ import type { ReactNode } from "react"
3
+ import { QueryClient } from "@tanstack/react-query"
4
+ import { afterEach, describe, expect, it, vi, type Mock } from "vitest"
5
+ import { NoToolTransportError } from "../lib/tool-query.js"
6
+ import { AppQueryProvider } from "../providers/query-provider.js"
7
+ import { flushEffects, hookProbe, renderWithEffects } from "../render-effects.js"
8
+ import { useToolMutation, useToolQuery } from "./use-tool-query.js"
9
+ import { useViewData } from "./use-view-data.js"
10
+ import { useViewToolQuery } from "./use-view-tool-query.js"
11
+
12
+ /**
13
+ * The data hooks' wiring with their effects running (toolkit#176 K18, N25):
14
+ * the key the REAL query uses, that a seeded feed does not fetch on mount,
15
+ * that a late transport fetches, and `useToolMutation`'s per-call callbacks
16
+ * and invalidation. The pure parts are pinned in `lib/tool-query.test.ts`.
17
+ */
18
+
19
+ type CallTool = (name: string, args: object) => Promise<unknown>
20
+
21
+ const ARGS = { id: 1 }
22
+ const answer = (data: unknown) => ({ structuredContent: data })
23
+
24
+ /**
25
+ * A client per test that keeps its cache (no garbage collection). The probes
26
+ * read the result after the render, so every change re-renders them (a widget
27
+ * reads its fields during the render, which TanStack tracks).
28
+ */
29
+ function testClient(): QueryClient {
30
+ return new QueryClient({
31
+ defaultOptions: { queries: { gcTime: Infinity, notifyOnChangeProps: "all" } },
32
+ })
33
+ }
34
+
35
+ function under(
36
+ client: QueryClient,
37
+ callTool: CallTool | undefined,
38
+ toolNameInQueryKey: boolean | undefined,
39
+ node: ReactNode,
40
+ ) {
41
+ return (
42
+ <AppQueryProvider client={client} callTool={callTool} toolNameInQueryKey={toolNameInQueryKey}>
43
+ {node}
44
+ </AppQueryProvider>
45
+ )
46
+ }
47
+
48
+ /** The keys of every query in the client's cache. */
49
+ const cachedKeys = (client: QueryClient) =>
50
+ client
51
+ .getQueryCache()
52
+ .findAll()
53
+ .map((query) => query.queryKey)
54
+
55
+ let unmount: (() => Promise<void>) | null = null
56
+
57
+ afterEach(async () => {
58
+ await unmount?.()
59
+ unmount = null
60
+ })
61
+
62
+ async function render(node: ReactNode) {
63
+ const view = await renderWithEffects(node)
64
+ unmount = view.unmount
65
+ return view
66
+ }
67
+
68
+ describe("the query hooks' real cache keys (K18)", () => {
69
+ it("useToolQuery fetches under [...key, tool, args] with toolNameInQueryKey, else the 2.x key", async () => {
70
+ for (const [flag, key] of [
71
+ [true, ["feed", "feed_data", ARGS]],
72
+ [undefined, ["feed", ARGS]],
73
+ ] as const) {
74
+ const client = testClient()
75
+ const callTool = vi.fn<CallTool>(() => Promise.resolve(answer({ n: 1 })))
76
+ const probe = hookProbe(() => useToolQuery(["feed"], "feed_data", ARGS))
77
+ await render(under(client, callTool, flag, <probe.Probe />))
78
+ await flushEffects()
79
+ expect(cachedKeys(client)).toEqual([key])
80
+ expect(client.getQueryData(key)).toEqual({ n: 1 })
81
+ expect(probe.current().data).toEqual({ n: 1 })
82
+ await unmount?.()
83
+ }
84
+ })
85
+
86
+ it("useViewToolQuery fetches under the same keys", async () => {
87
+ for (const [flag, key] of [
88
+ [true, ["view", "show_list", ARGS]],
89
+ [false, ["view", ARGS]],
90
+ ] as const) {
91
+ const client = testClient()
92
+ const callTool = vi.fn<CallTool>(() => Promise.resolve(answer({ rows: [] })))
93
+ const probe = hookProbe(() => useViewToolQuery(["view"], "show_list", ARGS))
94
+ await render(under(client, callTool, flag, <probe.Probe />))
95
+ await flushEffects()
96
+ expect(cachedKeys(client)).toEqual([key])
97
+ expect(callTool).toHaveBeenCalledWith("show_list", ARGS)
98
+ await unmount?.()
99
+ }
100
+ })
101
+
102
+ it('a seeded feed ("seed" mode) is read from the key the seed wrote: no call on mount', async () => {
103
+ for (const flag of [true, false]) {
104
+ const client = testClient()
105
+ const callTool = vi.fn<CallTool>(() => Promise.resolve(answer({ n: 2 })))
106
+ const probe = hookProbe(() =>
107
+ useViewData<{ n: number }>({ n: 1 }, ["feed"], "feed_data", ARGS, true, {
108
+ initialDataMode: "seed",
109
+ staleTime: 60_000,
110
+ }),
111
+ )
112
+ await render(under(client, callTool, flag, <probe.Probe />))
113
+ await flushEffects()
114
+ expect(callTool, "the seed answers the mount").not.toHaveBeenCalled()
115
+ expect(cachedKeys(client)).toHaveLength(1)
116
+ expect(probe.current()).toMatchObject({ data: { n: 1 }, loading: false })
117
+ await unmount?.()
118
+ }
119
+ })
120
+ })
121
+
122
+ describe("a tool transport that arrives after the first render (N25)", () => {
123
+ it("the query fetches once it arrives instead of keeping NoToolTransportError", async () => {
124
+ for (const useHook of [
125
+ () => useToolQuery<{ n: number }>(["late"], "late_data", ARGS),
126
+ () => useViewToolQuery<{ n: number }>(["late-view"], "late_view", ARGS),
127
+ ]) {
128
+ const client = testClient()
129
+ const probe = hookProbe(useHook)
130
+ const view = await render(under(client, undefined, undefined, <probe.Probe />))
131
+ await flushEffects()
132
+ expect(probe.current().error).toBeInstanceOf(NoToolTransportError)
133
+ const callTool = vi.fn<CallTool>(() => Promise.resolve(answer({ n: 3 })))
134
+ await view.rerender(under(client, callTool, undefined, <probe.Probe />))
135
+ await flushEffects()
136
+ expect(callTool).toHaveBeenCalledTimes(1)
137
+ expect(probe.current()).toMatchObject({ data: { n: 3 }, error: null })
138
+ await unmount?.()
139
+ }
140
+ })
141
+ })
142
+
143
+ describe("useToolMutation with its effects", () => {
144
+ type Mutation = ReturnType<typeof useToolMutation>
145
+
146
+ async function mountMutation(
147
+ client: QueryClient,
148
+ callTool: CallTool,
149
+ invalidateKeys?: unknown[][],
150
+ ) {
151
+ const probe = hookProbe(() => useToolMutation("retry_incident", { invalidateKeys }))
152
+ const view = await render(under(client, callTool, undefined, <probe.Probe />))
153
+ return { view, mutation: (): Mutation => probe.current() }
154
+ }
155
+
156
+ it("fires the per-call callbacks of EVERY call, not only the latest one", async () => {
157
+ const client = testClient()
158
+ const pending: ((value: unknown) => void)[] = []
159
+ const callTool = vi.fn<CallTool>(() => new Promise((resolve) => pending.push(resolve)))
160
+ const { mutation } = await mountMutation(client, callTool)
161
+ const first: Mock = vi.fn()
162
+ const second: Mock = vi.fn()
163
+ mutation().mutate({ id: "a" }, { onSuccess: first })
164
+ mutation().mutate({ id: "b" }, { onSuccess: second })
165
+ await flushEffects()
166
+ for (const resolve of pending) resolve(answer({ done: true }))
167
+ await flushEffects()
168
+ expect(first).toHaveBeenCalledTimes(1)
169
+ expect(first.mock.calls[0]?.slice(0, 2)).toEqual([{ done: true }, { id: "a" }])
170
+ expect(second).toHaveBeenCalledTimes(1)
171
+ expect(second.mock.calls[0]?.slice(0, 2)).toEqual([{ done: true }, { id: "b" }])
172
+ })
173
+
174
+ it("invalidates an inactive page under invalidateKeys without fetching it", async () => {
175
+ const client = testClient()
176
+ const pageFetch = vi.fn(() => Promise.resolve(["page 2"]))
177
+ await client.prefetchQuery({ queryKey: ["incidents", { page: 2 }], queryFn: pageFetch })
178
+ expect(pageFetch).toHaveBeenCalledTimes(1)
179
+ const callTool = vi.fn<CallTool>(() => Promise.resolve(answer({ done: true })))
180
+ const { mutation } = await mountMutation(client, callTool, [["incidents"]])
181
+ const onSuccess = vi.fn()
182
+ mutation().mutate({ id: "a" }, { onSuccess })
183
+ await flushEffects()
184
+ expect(onSuccess).toHaveBeenCalledTimes(1)
185
+ expect(client.getQueryState(["incidents", { page: 2 }])?.isInvalidated).toBe(true)
186
+ expect(pageFetch, "an inactive page waits for its next use").toHaveBeenCalledTimes(1)
187
+ })
188
+
189
+ it("fires no per-call callback once the component unmounted", async () => {
190
+ const client = testClient()
191
+ let resolve: (value: unknown) => void = () => {}
192
+ const callTool = vi.fn<CallTool>(() => new Promise((r) => (resolve = r)))
193
+ const { view, mutation } = await mountMutation(client, callTool)
194
+ const onSuccess = vi.fn()
195
+ const onSettled = vi.fn()
196
+ mutation().mutate({ id: "a" }, { onSuccess, onSettled })
197
+ await flushEffects()
198
+ await view.unmount()
199
+ resolve(answer({ done: true }))
200
+ await flushEffects()
201
+ expect(callTool).toHaveBeenCalledTimes(1)
202
+ expect(onSuccess).not.toHaveBeenCalled()
203
+ expect(onSettled).not.toHaveBeenCalled()
204
+ })
205
+ })