@miragon/mcp-toolkit-ui 2.7.0 → 2.8.0

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 (387) 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 +9 -6
  122. package/dist/components/DrillButton.d.ts.map +1 -1
  123. package/dist/components/DrillButton.js +8 -6
  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.tsx +23 -10
  319. package/src/components/EmptyState.tsx +6 -4
  320. package/src/components/ErrorState.tsx +31 -9
  321. package/src/components/FilterBar.test.tsx +82 -0
  322. package/src/components/FilterBar.tsx +52 -26
  323. package/src/components/KpiGrid.test.tsx +279 -0
  324. package/src/components/KpiGrid.tsx +297 -90
  325. package/src/components/ListFooter.test.tsx +116 -0
  326. package/src/components/ListFooter.tsx +102 -15
  327. package/src/components/PagedListFooter.test.tsx +140 -0
  328. package/src/components/PagedListFooter.tsx +107 -0
  329. package/src/components/Pills.test.tsx +77 -0
  330. package/src/components/Pills.tsx +63 -10
  331. package/src/components/SectionHeading.tsx +9 -3
  332. package/src/components/Toast.tsx +37 -19
  333. package/src/components/WidgetHeader.test.tsx +78 -0
  334. package/src/components/WidgetHeader.tsx +63 -16
  335. package/src/components/feedback-states.test.tsx +98 -9
  336. package/src/components/tone-figures.test.tsx +8 -4
  337. package/src/hooks/index.ts +6 -3
  338. package/src/hooks/tool-result-recovery-model.test.ts +744 -0
  339. package/src/hooks/tool-result-recovery-model.ts +418 -0
  340. package/src/hooks/use-tool-query.effects.test.tsx +205 -0
  341. package/src/hooks/use-tool-query.ts +156 -33
  342. package/src/hooks/use-tool-result-recovery.effects.test.tsx +141 -0
  343. package/src/hooks/use-tool-result-recovery.test.ts +3 -4
  344. package/src/hooks/use-tool-result-recovery.ts +84 -142
  345. package/src/hooks/use-view-data.test.tsx +118 -9
  346. package/src/hooks/use-view-data.ts +128 -18
  347. package/src/hooks/use-view-tool-query.ts +25 -19
  348. package/src/i18n-coverage.test.tsx +104 -2
  349. package/src/index.ts +48 -11
  350. package/src/lib/focus-return.test.ts +288 -0
  351. package/src/lib/focus-return.ts +96 -0
  352. package/src/lib/parse-tool-result.test.ts +46 -1
  353. package/src/lib/parse-tool-result.ts +40 -2
  354. package/src/lib/toast-model.test.ts +166 -1
  355. package/src/lib/toast-model.ts +104 -2
  356. package/src/lib/tone-utils.test.ts +86 -10
  357. package/src/lib/tone-utils.ts +89 -8
  358. package/src/lib/tool-query.test.ts +569 -0
  359. package/src/lib/tool-query.ts +253 -0
  360. package/src/lib/tool-result-observer.test.ts +296 -0
  361. package/src/lib/tool-result-observer.ts +211 -0
  362. package/src/lib/typography.test.ts +45 -0
  363. package/src/lib/typography.ts +47 -0
  364. package/src/lib/utils.ts +12 -1
  365. package/src/locale/index.ts +1 -0
  366. package/src/locale/message-types.ts +118 -2
  367. package/src/locale/messages-de.ts +49 -10
  368. package/src/locale/messages-en.ts +49 -10
  369. package/src/locale/toolkit-messages.test.ts +133 -0
  370. package/src/locale/toolkit-messages.ts +63 -2
  371. package/src/primitives/alert.tsx +3 -0
  372. package/src/primitives/badge.tsx +9 -4
  373. package/src/primitives/button.tsx +1 -1
  374. package/src/primitives/dialog.tsx +10 -1
  375. package/src/primitives/select.tsx +1 -1
  376. package/src/primitives/sheet.tsx +7 -0
  377. package/src/providers/query-provider.test.tsx +154 -0
  378. package/src/providers/query-provider.tsx +66 -5
  379. package/src/render-cases-primitives.tsx +1 -1
  380. package/src/render-cases.tsx +38 -6
  381. package/src/render-effects.tsx +96 -0
  382. package/src/theme/cd-tokens.test.ts +3 -1
  383. package/src/theme/create-theme.test.ts +10 -0
  384. package/src/theme/create-theme.ts +28 -0
  385. package/src/theme/theme-css.test.ts +51 -0
  386. package/src/theme.css +31 -1
  387. package/ui-catalog.json +448 -41
@@ -0,0 +1,89 @@
1
+ import { useCallback, useState } from "react"
2
+ import type { QueryClient } from "@tanstack/react-query"
3
+ import { useDisplayMode } from "mcp-use/react"
4
+ import { useToast } from "../components/Toast.js"
5
+ import type { ViewMessages } from "../locale/toolkit-messages.js"
6
+ import { fullscreenAvailable, isCompleteViewData, type ViewData } from "./mcp-app-view-chrome.js"
7
+ import { decodeViewResult } from "./view-lifecycle.js"
8
+
9
+ /**
10
+ * The toolbar actions of `McpAppView`: refresh and the fullscreen toggle.
11
+ * Their failures raise a toast in catalog words; the raw reason goes to the
12
+ * console, never to the user.
13
+ */
14
+
15
+ /**
16
+ * Refresh via the refresh tool. A failure keeps the data and says so in a
17
+ * toast. A refresh that went through invalidates the queries of `client`,
18
+ * the one the view's widgets fetch through.
19
+ */
20
+ export function useViewRefresh(options: {
21
+ viewData: ViewData | null
22
+ setViewData: (next: ViewData) => void
23
+ callTool: (name: string, args: Record<string, unknown>) => Promise<unknown>
24
+ refreshToolName: string
25
+ labels: Pick<ViewMessages, "refreshFailed" | "refreshFailedHint">
26
+ client: QueryClient
27
+ }) {
28
+ const { viewData, setViewData, callTool, refreshToolName, labels, client } = options
29
+ const toast = useToast()
30
+ const [isRefreshing, setIsRefreshing] = useState(false)
31
+ const refreshView = useCallback(async () => {
32
+ if (!viewData?._refreshParams) return
33
+ setIsRefreshing(true)
34
+ let failure: unknown = null
35
+ try {
36
+ const result = await callTool(
37
+ refreshToolName,
38
+ viewData._refreshParams as unknown as Record<string, unknown>,
39
+ )
40
+ // One decoding convention with `useToolQuery` (structured-first), with a
41
+ // typed outcome: an unusable payload is a failure, never exception text.
42
+ const outcome = decodeViewResult(result, isCompleteViewData)
43
+ if (outcome.ok) setViewData(outcome.data)
44
+ else failure = outcome.reason ?? "the refresh tool returned no view"
45
+ // The call went through, so self-fetching widgets refresh as well;
46
+ // a tool error refreshes nothing.
47
+ if (outcome.ok || !outcome.isError) void client.invalidateQueries()
48
+ } catch (e) {
49
+ failure = e
50
+ } finally {
51
+ setIsRefreshing(false)
52
+ }
53
+ if (failure === null) return
54
+ console.warn("[mcp-toolkit] refresh failed:", failure)
55
+ toast.show({
56
+ tone: "danger",
57
+ title: labels.refreshFailed,
58
+ description: labels.refreshFailedHint,
59
+ })
60
+ }, [callTool, client, labels, refreshToolName, setViewData, toast, viewData])
61
+ return { isRefreshing, refreshView }
62
+ }
63
+
64
+ /** The fullscreen toggle, gated on the host's offer; a rejected request raises a toast. */
65
+ export function useFullscreenToggle(
66
+ labels: Pick<ViewMessages, "fullscreenFailed" | "fullscreenFailedHint">,
67
+ ) {
68
+ const { displayMode, availableDisplayModes, requestDisplayMode } = useDisplayMode()
69
+ const toast = useToast()
70
+ const toggle = useCallback(async () => {
71
+ const mode = displayMode === "fullscreen" ? "inline" : "fullscreen"
72
+ try {
73
+ // Advisory since 2.x: `displayMode` updates when the host applies it.
74
+ await requestDisplayMode({ mode })
75
+ } catch (e) {
76
+ console.warn("[mcp-toolkit] display-mode request failed:", e)
77
+ toast.show({
78
+ tone: "danger",
79
+ title: labels.fullscreenFailed,
80
+ description: labels.fullscreenFailedHint,
81
+ })
82
+ }
83
+ }, [displayMode, labels, requestDisplayMode, toast])
84
+ return {
85
+ isFullscreen: displayMode === "fullscreen",
86
+ canFullscreen: fullscreenAvailable(displayMode, availableDisplayModes),
87
+ toggle,
88
+ }
89
+ }
@@ -18,6 +18,10 @@ export interface UseViewLifecycleOptions<T> {
18
18
  cancelled: boolean
19
19
  callError: unknown
20
20
  recoveryFailed: boolean
21
+ /** The recovery policy kept the view from re-running a stripped result's tool. */
22
+ recoveryBlocked?: boolean
23
+ /** The host reports the originating tool as writing (no retry after a block). */
24
+ toolWrites?: boolean
21
25
  /** The originating tool (`hostContext.toolInfo`); a retry needs it. */
22
26
  toolName: string | undefined
23
27
  toolInput: Record<string, unknown> | null | undefined
@@ -47,6 +51,8 @@ export function useViewLifecycle<T>(options: UseViewLifecycleOptions<T>): {
47
51
  cancelled: options.cancelled,
48
52
  callErrorReason: errorMessage(options.callError),
49
53
  recoveryFailed: options.recoveryFailed,
54
+ recoveryBlocked: options.recoveryBlocked,
55
+ toolWrites: options.toolWrites,
50
56
  retryState,
51
57
  slow: false,
52
58
  canRetry: toolName !== undefined,
@@ -2,7 +2,9 @@ import { describe, expect, it } from "vitest"
2
2
  import {
3
3
  decodeViewResult,
4
4
  DEFAULT_SLOW_HINT_MS,
5
+ describeViewForModel,
5
6
  errorMessage,
7
+ reloadedAtOf,
6
8
  resolveViewLifecycle,
7
9
  RETRY_IDLE,
8
10
  retryFocusTarget,
@@ -51,6 +53,53 @@ describe("resolveViewLifecycle (toolkit#176 state machine)", () => {
51
53
  })
52
54
  })
53
55
 
56
+ it("a blocked recovery is its own error kind: the tool ran, the view must not re-run it", () => {
57
+ // A tool the host reports as writing: no retry, one click would write twice.
58
+ expect(at({ recoveryBlocked: true, toolWrites: true })).toEqual({
59
+ phase: "error",
60
+ reason: undefined,
61
+ canRetry: false,
62
+ kind: "blocked",
63
+ })
64
+ // Any other tool (a stricter policy blocked it): loading it is the user's call.
65
+ expect(at({ recoveryBlocked: true, toolWrites: false })).toEqual({
66
+ phase: "error",
67
+ reason: undefined,
68
+ canRetry: true,
69
+ kind: "blocked",
70
+ })
71
+ expect(at({ recoveryBlocked: true })).toMatchObject({ canRetry: true, kind: "blocked" })
72
+ expect(at({ recoveryBlocked: true, canRetry: false })).toMatchObject({ canRetry: false })
73
+ // toolWrites alone changes nothing: a failure keeps its retry.
74
+ expect(at({ recoveryFailed: true, toolWrites: true })).toEqual({
75
+ phase: "error",
76
+ reason: undefined,
77
+ canRetry: true,
78
+ })
79
+ expect(at({ callStatus: "error", callErrorReason: "x", toolWrites: true })).toMatchObject({
80
+ canRetry: true,
81
+ })
82
+ })
83
+
84
+ it("a block yields to a payload, a retry, a cancellation and a call error, and wins over a failure", () => {
85
+ const blocked = { recoveryBlocked: true, toolWrites: true }
86
+ expect(at({ ...blocked, hasViewData: true })).toEqual({ phase: "result" })
87
+ expect(at({ ...blocked, retryState: { status: "running", attempt: 1 } })).toEqual({
88
+ phase: "waiting",
89
+ slow: false,
90
+ })
91
+ expect(
92
+ at({ ...blocked, retryState: { status: "failed", attempt: 1, reason: "down" } }),
93
+ ).toEqual({ phase: "error", reason: "down", canRetry: true })
94
+ expect(at({ ...blocked, cancelled: true })).toEqual({ phase: "cancelled" })
95
+ expect(at({ ...blocked, callStatus: "error", callErrorReason: "x" })).toEqual({
96
+ phase: "error",
97
+ reason: "x",
98
+ canRetry: true,
99
+ })
100
+ expect(at({ ...blocked, recoveryFailed: true })).toMatchObject({ kind: "blocked" })
101
+ })
102
+
54
103
  it("offers a retry only when the originating tool is known", () => {
55
104
  expect(at({ callStatus: "error", canRetry: false })).toMatchObject({
56
105
  phase: "error",
@@ -225,3 +274,170 @@ describe("errorMessage", () => {
225
274
  expect(errorMessage(undefined)).toBeUndefined()
226
275
  })
227
276
  })
277
+
278
+ describe("describeViewForModel (the view's model context, toolkit#176 K20)", () => {
279
+ const error = (reason: string | undefined, canRetry = true) =>
280
+ ({ phase: "error", reason, canRetry }) as const
281
+
282
+ it("an error says what failed and that the user sees an error instead of the view", () => {
283
+ expect(
284
+ describeViewForModel({ lifecycle: error("[503] engine down"), toolName: "render-view" }),
285
+ ).toBe(
286
+ "The view of render-view could not be shown (reason: [503] engine down). " +
287
+ "The user sees an error message with a retry button instead of the view.",
288
+ )
289
+ expect(describeViewForModel({ lifecycle: error(undefined, false), toolName: undefined })).toBe(
290
+ "The view could not be shown. The user sees an error message instead of the view.",
291
+ )
292
+ })
293
+
294
+ it("a block says the tool ran, nothing failed, and the model must not re-run it to show the view", () => {
295
+ const blocked = (canRetry: boolean) =>
296
+ ({ phase: "error", reason: undefined, canRetry, kind: "blocked" }) as const
297
+ expect(describeViewForModel({ lifecycle: blocked(false), toolName: "start_and_show" })).toBe(
298
+ "The view of start_and_show is not shown: start_and_show ran, but the host did not pass its " +
299
+ "result to the view, and the view did not re-run it. The user sees a note to ask in the chat " +
300
+ "for the current state instead of the view. Do not re-run start_and_show just to show the view.",
301
+ )
302
+ expect(describeViewForModel({ lifecycle: blocked(true), toolName: undefined })).toBe(
303
+ "The view is not shown: the tool ran, but the host did not pass its result to the view, " +
304
+ "and the view did not re-run it. The user sees a note with a button that loads the view " +
305
+ "instead of the view. Do not re-run the tool just to show the view.",
306
+ )
307
+ })
308
+
309
+ it("a cancellation says so", () => {
310
+ expect(describeViewForModel({ lifecycle: { phase: "cancelled" }, toolName: "show_x" })).toBe(
311
+ "The view of show_x was cancelled before it rendered. The user sees a note that the call was cancelled.",
312
+ )
313
+ })
314
+
315
+ it("nothing while waiting, nor for a plain host-delivered view", () => {
316
+ expect(
317
+ describeViewForModel({ lifecycle: { phase: "waiting", slow: true }, toolName: "t" }),
318
+ ).toBeNull()
319
+ // A waiting view shows no data, so a stamp or step errors of an older payload say nothing.
320
+ expect(
321
+ describeViewForModel({
322
+ lifecycle: { phase: "waiting", slow: false },
323
+ toolName: "t",
324
+ reloadedAt: Date.UTC(2026, 9, 11),
325
+ stepErrors: [{ stepId: "kpi", reason: "timeout" }],
326
+ }),
327
+ ).toBeNull()
328
+ expect(describeViewForModel({ lifecycle: { phase: "result" }, toolName: "t" })).toBeNull()
329
+ expect(
330
+ describeViewForModel({
331
+ lifecycle: { phase: "result" },
332
+ toolName: "t",
333
+ stepErrors: [],
334
+ reloadedAt: null,
335
+ }),
336
+ ).toBeNull()
337
+ })
338
+
339
+ it("failed data steps of a shown view, with or without the alert's retry", () => {
340
+ const stepErrors = [
341
+ { stepId: "kpi", reason: "timeout" },
342
+ { stepId: "list", reason: "[404] gone" },
343
+ ]
344
+ expect(
345
+ describeViewForModel({
346
+ lifecycle: { phase: "result" },
347
+ toolName: "render-view",
348
+ stepErrors,
349
+ stepsRetryable: true,
350
+ }),
351
+ ).toBe(
352
+ "The view of render-view is shown, but some of its data could not be loaded " +
353
+ "(kpi: timeout; list: [404] gone). The user sees an alert with a retry button.",
354
+ )
355
+ expect(
356
+ describeViewForModel({ lifecycle: { phase: "result" }, toolName: undefined, stepErrors }),
357
+ ).toBe(
358
+ "The view is shown, but some of its data could not be loaded " +
359
+ "(kpi: timeout; list: [404] gone). The user sees an alert.",
360
+ )
361
+ })
362
+
363
+ it("a payload the view re-loaded itself is live as of then (N23)", () => {
364
+ const at = Date.UTC(2026, 9, 11, 14, 32, 5)
365
+ const recovered =
366
+ "The view of t re-ran the tool at 2026-10-11T14:32:05.000Z because the host did not pass " +
367
+ "the original result to the view; its figures are live as of then and can differ from the original result."
368
+ expect(
369
+ describeViewForModel({ lifecycle: { phase: "result" }, toolName: "t", reloadedAt: at }),
370
+ ).toBe(recovered)
371
+ expect(
372
+ describeViewForModel({
373
+ lifecycle: { phase: "result" },
374
+ toolName: "t",
375
+ reloadedAt: at,
376
+ reloadedBy: "recovery",
377
+ }),
378
+ ).toBe(recovered)
379
+ expect(
380
+ describeViewForModel({
381
+ lifecycle: { phase: "result" },
382
+ toolName: "t",
383
+ reloadedAt: at,
384
+ stepErrors: [{ stepId: "kpi", reason: "timeout" }],
385
+ }),
386
+ ).toMatch(
387
+ /original result\. Some of its data could not be loaded \(kpi: timeout\)\. The user sees an alert\.$/,
388
+ )
389
+ })
390
+
391
+ it("a refreshed payload says the user refreshed it, and when", () => {
392
+ expect(
393
+ describeViewForModel({
394
+ lifecycle: { phase: "result" },
395
+ toolName: "t",
396
+ reloadedAt: Date.UTC(2026, 9, 11, 15, 10),
397
+ reloadedBy: "refresh",
398
+ }),
399
+ ).toBe(
400
+ "The view of t was refreshed by the user at 2026-10-11T15:10:00.000Z; its figures are live " +
401
+ "as of then and can differ from the original result.",
402
+ )
403
+ })
404
+
405
+ it("a payload applied from the view builder says the user edited it there, and when it loaded", () => {
406
+ expect(
407
+ describeViewForModel({
408
+ lifecycle: { phase: "result" },
409
+ toolName: "show_orders_dashboard",
410
+ reloadedAt: Date.UTC(2026, 9, 11, 10, 5),
411
+ reloadedBy: "builder",
412
+ }),
413
+ ).toBe(
414
+ "The view of show_orders_dashboard was edited by the user in the view builder, which loaded " +
415
+ "its data again at 2026-10-11T10:05:00.000Z; its figures are live as of then and can " +
416
+ "differ from the original result.",
417
+ )
418
+ })
419
+ })
420
+
421
+ describe("reloadedAtOf (which payload carries the data-as-of stamp, N23)", () => {
422
+ const now = 42_000
423
+
424
+ it("a recovery, a refresh and a builder Apply always: the view loaded the figures itself", () => {
425
+ expect(reloadedAtOf("recovered", now, false)).toBe(now)
426
+ expect(reloadedAtOf("refresh", now, false)).toBe(now)
427
+ expect(reloadedAtOf("refresh", now, true)).toBe(now)
428
+ expect(reloadedAtOf("builder", now, false)).toBe(now)
429
+ expect(reloadedAtOf("builder", now, true)).toBe(now)
430
+ })
431
+
432
+ it("a retry only when the original result reached the chat", () => {
433
+ // After a blocked or failed recovery: the host answered, the chat has figures.
434
+ expect(reloadedAtOf("retry", now, false)).toBe(now)
435
+ // After a failed or cancelled call: the model never had figures.
436
+ expect(reloadedAtOf("retry", now, true)).toBeNull()
437
+ })
438
+
439
+ it("never the host's own payload", () => {
440
+ expect(reloadedAtOf("host", now, false)).toBeNull()
441
+ expect(reloadedAtOf("host", now, true)).toBeNull()
442
+ })
443
+ })
@@ -6,6 +6,8 @@
6
6
  * waiting ──result──▶ result
7
7
  * │ ╲
8
8
  * │ ╲──tool error / failed recovery / unrecoverable result──▶ error ──retry──▶ waiting
9
+ * │ ╲
10
+ * │ ╲──stripped result the policy must not re-load──▶ error (blocked)
9
11
  * │
10
12
  * └──host cancelled──▶ cancelled (terminal: the user stopped it)
11
13
  *
@@ -14,6 +16,10 @@
14
16
  * skeleton, and a retry re-runs the originating tool once on the user's click.
15
17
  * A latched result that is no view on a host that names no originating tool
16
18
  * is an `error` too: nothing could recover it, so waiting would never end.
19
+ * A `blocked` error is no failure: the tool ran, the host kept its result
20
+ * from the view, and the recovery policy forbids re-running it. It offers to
21
+ * load the view only for a tool the host does not report as writing, so one
22
+ * click never runs a write a second time (CI §9).
17
23
  * Pure functions only: the hook in `use-view-lifecycle.ts` feeds them.
18
24
  */
19
25
  import { parseToolResult } from "../lib/parse-tool-result.js"
@@ -53,8 +59,23 @@ export interface ViewLifecycleInput {
53
59
  cancelled: boolean
54
60
  /** Message of the original call's error, when it failed. */
55
61
  callErrorReason: string | undefined
56
- /** The automatic stripped-result recovery ran and failed. */
62
+ /**
63
+ * The automatic stripped-result recovery ran and failed, or was needed but
64
+ * could not run because the host names no tool.
65
+ */
57
66
  recoveryFailed: boolean
67
+ /**
68
+ * The host did not pass the result to the view, and the recovery policy
69
+ * forbids re-running the tool: it ran, but the view never got its result.
70
+ * Default `false`.
71
+ */
72
+ recoveryBlocked?: boolean
73
+ /**
74
+ * The host reports the originating tool as writing (`readOnlyHint: false`
75
+ * or `destructiveHint: true`). A blocked state then offers no retry: one
76
+ * click would run the write a second time. Default `false`.
77
+ */
78
+ toolWrites?: boolean
58
79
  /** The user-triggered retry. */
59
80
  retryState: RetryState
60
81
  /** Loading has outlasted the slow-hint delay. */
@@ -63,18 +84,32 @@ export interface ViewLifecycleInput {
63
84
  canRetry: boolean
64
85
  }
65
86
 
87
+ /**
88
+ * What an `error` phase stands for: `"failed"` (the call, the recovery or the
89
+ * retry failed) or `"blocked"` (the tool ran, but the host did not pass its
90
+ * result to the view, and the recovery policy keeps the view from re-running it).
91
+ */
92
+ export type ViewErrorKind = "failed" | "blocked"
93
+
66
94
  export type ViewLifecycle =
67
95
  | { phase: "result" }
68
96
  | { phase: "waiting"; slow: boolean }
69
97
  | { phase: "cancelled" }
70
- | { phase: "error"; reason: string | undefined; canRetry: boolean }
98
+ | {
99
+ phase: "error"
100
+ reason: string | undefined
101
+ canRetry: boolean
102
+ /** Absent for a failure; `"blocked"` for a result the view must not re-load. */
103
+ kind?: ViewErrorKind
104
+ }
71
105
 
72
106
  /**
73
107
  * Resolve the single phase the view is in. Precedence: a payload always wins;
74
108
  * a running retry is waiting; a failed retry is an error with ITS reason; a
75
- * cancellation is terminal; a failed call, a failed recovery or a latched
76
- * non-view result nothing can recover is an error; everything else is still
77
- * waiting.
109
+ * cancellation is terminal; a failed call is an error; a blocked recovery is
110
+ * a `blocked` error, retryable only for a tool not reported as writing; a
111
+ * failed recovery or a latched non-view result nothing can recover is an
112
+ * error; everything else is still waiting.
78
113
  */
79
114
  export function resolveViewLifecycle(input: ViewLifecycleInput): ViewLifecycle {
80
115
  if (input.hasViewData) return { phase: "result" }
@@ -87,6 +122,10 @@ export function resolveViewLifecycle(input: ViewLifecycleInput): ViewLifecycle {
87
122
  if (input.callStatus === "error") {
88
123
  return { phase: "error", reason: input.callErrorReason, canRetry: input.canRetry }
89
124
  }
125
+ if (input.recoveryBlocked) {
126
+ const canRetry = input.canRetry && !input.toolWrites
127
+ return { phase: "error", reason: undefined, canRetry, kind: "blocked" }
128
+ }
90
129
  if (input.recoveryFailed) return { phase: "error", reason: undefined, canRetry: input.canRetry }
91
130
  // A latched result without a renderable view: the recovery re-runs the
92
131
  // originating tool, so it can only help when the host names that tool.
@@ -166,6 +205,127 @@ export function decodeViewResult<T>(
166
205
  : { ok: false, isError: false, reason: undefined }
167
206
  }
168
207
 
208
+ /** What {@link describeViewForModel} reports on. */
209
+ export interface ViewModelContextInput {
210
+ lifecycle: ViewLifecycle
211
+ /** The originating tool, when the host names it. */
212
+ toolName: string | undefined
213
+ /** The shown payload's failed data steps (result phase). */
214
+ stepErrors?: readonly { stepId: string; reason: string }[]
215
+ /** The step-error alert offers a retry (the view can refresh). */
216
+ stepsRetryable?: boolean
217
+ /**
218
+ * When the view re-loaded the shown payload itself (epoch ms, see
219
+ * {@link reloadedAtOf}); `null` for a payload the host delivered.
220
+ */
221
+ reloadedAt?: number | null
222
+ /**
223
+ * How it re-loaded: it re-ran the tool because the host did not pass the
224
+ * result along (`"recovery"`, the default: its own recovery or the user's
225
+ * retry), the user refreshed the view (`"refresh"`), or the user applied an
226
+ * edit from the view builder, which ran the data steps again (`"builder"`).
227
+ */
228
+ reloadedBy?: "recovery" | "refresh" | "builder"
229
+ }
230
+
231
+ /**
232
+ * Where the shown payload came from: the host's tool result, the view's own
233
+ * re-execution when the host stripped it, the user's retry, a refresh, or the
234
+ * user's Apply in the view builder (its figures are the builder's own run of
235
+ * the data steps).
236
+ */
237
+ export type ViewSource = "host" | "recovered" | "retry" | "refresh" | "builder"
238
+
239
+ /**
240
+ * When a payload counts as re-loaded by the view itself (N23): `at` when its
241
+ * figures can differ from the result the chat answered from, else `null`. A
242
+ * recovery, a refresh and a builder Apply always; a retry only after a call
243
+ * whose result reached the chat (a stripped or unusable one), never after a
244
+ * failed or cancelled call, of which the model never had figures. The host's
245
+ * own payload never.
246
+ */
247
+ export function reloadedAtOf(source: ViewSource, at: number, callFailed: boolean): number | null {
248
+ if (source === "host") return null
249
+ if (source === "retry" && callFailed) return null
250
+ return at
251
+ }
252
+
253
+ /** How the view says, to the model, that it loaded the shown figures itself. */
254
+ const RELOADED_BY: Record<
255
+ NonNullable<ViewModelContextInput["reloadedBy"]>,
256
+ (at: string) => string
257
+ > = {
258
+ recovery: (at) =>
259
+ `re-ran the tool at ${at} because the host did not pass the original result to the view`,
260
+ refresh: (at) => `was refreshed by the user at ${at}`,
261
+ builder: (at) =>
262
+ `was edited by the user in the view builder, which loaded its data again at ${at}`,
263
+ }
264
+
265
+ /**
266
+ * The view's own line of model context (toolkit#176 K20): what failed and
267
+ * that the user sees an error instead of the view; a blocked recovery (the
268
+ * tool ran, its result never reached the view, and the model must not re-run
269
+ * it just to show the view); failed data steps; a payload the view re-loaded
270
+ * itself (a recovery, a retry, a refresh, a builder Apply), whose figures can
271
+ * differ from the tool result the model answered from (N23). `null` while
272
+ * there is nothing to report (waiting, or a plain host-delivered view).
273
+ * English: it is read by the model, never shown.
274
+ */
275
+ export function describeViewForModel(input: ViewModelContextInput): string | null {
276
+ const subject = input.toolName ? `The view of ${input.toolName}` : "The view"
277
+ const { lifecycle } = input
278
+ if (lifecycle.phase === "error") {
279
+ if (lifecycle.kind === "blocked") {
280
+ return describeBlockedForModel(subject, input.toolName, lifecycle.canRetry)
281
+ }
282
+ const reason = lifecycle.reason ? ` (reason: ${lifecycle.reason})` : ""
283
+ const offer = lifecycle.canRetry ? "an error message with a retry button" : "an error message"
284
+ return `${subject} could not be shown${reason}. The user sees ${offer} instead of the view.`
285
+ }
286
+ if (lifecycle.phase === "cancelled") {
287
+ return `${subject} was cancelled before it rendered. The user sees a note that the call was cancelled.`
288
+ }
289
+ if (lifecycle.phase === "waiting") return null
290
+ const lines: string[] = []
291
+ if (input.reloadedAt != null) {
292
+ const at = new Date(input.reloadedAt).toISOString()
293
+ const how = RELOADED_BY[input.reloadedBy ?? "recovery"](at)
294
+ lines.push(
295
+ `${subject} ${how}; its figures are live as of then and can differ from the original result.`,
296
+ )
297
+ }
298
+ const failed = input.stepErrors ?? []
299
+ if (failed.length > 0) {
300
+ const steps = failed.map((e) => `${e.stepId}: ${e.reason}`).join("; ")
301
+ const lead = lines.length > 0 ? "Some" : `${subject} is shown, but some`
302
+ const offer = input.stepsRetryable ? "an alert with a retry button" : "an alert"
303
+ lines.push(`${lead} of its data could not be loaded (${steps}). The user sees ${offer}.`)
304
+ }
305
+ return lines.length > 0 ? lines.join(" ") : null
306
+ }
307
+
308
+ /**
309
+ * The model's line for a blocked recovery: the tool ran (nothing failed), the
310
+ * host kept its result from the view, the view did not re-run it, and the
311
+ * model must not re-run it just to show the view, a write least of all.
312
+ */
313
+ function describeBlockedForModel(
314
+ subject: string,
315
+ toolName: string | undefined,
316
+ canRetry: boolean,
317
+ ): string {
318
+ const tool = toolName ?? "the tool"
319
+ const offer = canRetry
320
+ ? "a note with a button that loads the view"
321
+ : "a note to ask in the chat for the current state"
322
+ return (
323
+ `${subject} is not shown: ${tool} ran, but the host did not pass its result to the view, ` +
324
+ `and the view did not re-run it. The user sees ${offer} instead of the view. ` +
325
+ `Do not re-run ${tool} just to show the view.`
326
+ )
327
+ }
328
+
169
329
  /** A displayable message for anything thrown. */
170
330
  export function errorMessage(err: unknown): string | undefined {
171
331
  if (err instanceof Error) return err.message || undefined
@@ -0,0 +1,51 @@
1
+ // @vitest-environment happy-dom
2
+ import { useEffect } from "react"
3
+ import { afterEach, describe, expect, it } from "vitest"
4
+ import { useToast } from "../components/Toast.js"
5
+ import { TOOLKIT_MESSAGES } from "../locale/toolkit-messages.js"
6
+ import { renderWithEffects, type EffectRender } from "../render-effects.js"
7
+ import { WidgetFixtureHost } from "./widget-fixture.js"
8
+
9
+ /**
10
+ * The harness's toast stack inside the host's locale scope, with its effects
11
+ * running: a toast exists only after a widget's effect shows it, so the SSR
12
+ * suite (`widget-fixture.render.test.tsx`) never sees its words. Its one
13
+ * localized word is the dismiss button's name; it follows `globals.locale`
14
+ * like `McpAppView`'s stack does, which holds only while the `ToastProvider`
15
+ * sits inside the `ToolkitLocaleFallback`.
16
+ */
17
+
18
+ /** A widget that reports a write's outcome as a toast once it mounts. */
19
+ function SavesOnMount() {
20
+ const toast = useToast()
21
+ useEffect(() => {
22
+ toast.show({ tone: "success", title: "Saved" })
23
+ }, [toast])
24
+ return <p>widget</p>
25
+ }
26
+
27
+ let mounted: EffectRender | null = null
28
+
29
+ afterEach(async () => {
30
+ await mounted?.unmount()
31
+ mounted = null
32
+ })
33
+
34
+ /** The dismiss button's accessible name of the toast the widget raised. */
35
+ async function dismissLabel(globals?: Record<string, unknown>): Promise<string | null> {
36
+ mounted = await renderWithEffects(<WidgetFixtureHost widget={SavesOnMount} globals={globals} />)
37
+ const toast = mounted.container.querySelector('[data-slot="toast"]')
38
+ expect(toast?.textContent, "the widget's toast reached the stack").toContain("Saved")
39
+ return toast?.querySelector("button")?.getAttribute("aria-label") ?? null
40
+ }
41
+
42
+ describe("WidgetFixtureHost toast stack (effects)", () => {
43
+ it("names a toast's dismiss button in globals.locale", async () => {
44
+ expect(await dismissLabel({ locale: "de" })).toBe(TOOLKIT_MESSAGES.de.common.dismiss)
45
+ })
46
+
47
+ it("names it in English without a host locale", async () => {
48
+ expect(await dismissLabel()).toBe(TOOLKIT_MESSAGES.en.common.dismiss)
49
+ expect(TOOLKIT_MESSAGES.en.common.dismiss).not.toBe(TOOLKIT_MESSAGES.de.common.dismiss)
50
+ })
51
+ })