@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
@@ -14,7 +14,8 @@ import {
14
14
  type ViewData,
15
15
  type ViewToolbarProps,
16
16
  } from "./mcp-app-view-chrome.js"
17
- import { ViewPlaceholder } from "./mcp-app-view-states.js"
17
+ import { ViewPlaceholder, type DescribeViewError } from "./mcp-app-view-states.js"
18
+ import { VIEW_FOCUS_TARGET } from "./use-build-mode-focus.js"
18
19
  import { resolveViewLifecycle, RETRY_IDLE, type ViewLifecycleInput } from "./view-lifecycle.js"
19
20
 
20
21
  const en = TOOLKIT_MESSAGES.en.view
@@ -102,6 +103,90 @@ describe("ViewPlaceholder — one rendering per lifecycle phase (toolkit#176)",
102
103
  expect(html).toContain("HTTP 503")
103
104
  })
104
105
 
106
+ it("blocked, writing tool: a notice that the action ran, no button that would run it again (CI §9)", () => {
107
+ // The exact input McpAppView feeds when the policy keeps a stripped
108
+ // result of a tool reported as writing from being re-run.
109
+ const lifecycle = resolveViewLifecycle({
110
+ hasViewData: false,
111
+ callStatus: "pending",
112
+ cancelled: false,
113
+ callErrorReason: undefined,
114
+ recoveryFailed: false,
115
+ recoveryBlocked: true,
116
+ toolWrites: true,
117
+ retryState: RETRY_IDLE,
118
+ slow: false,
119
+ canRetry: true,
120
+ } satisfies ViewLifecycleInput)
121
+ expect(lifecycle).toEqual({
122
+ phase: "error",
123
+ reason: undefined,
124
+ canRetry: false,
125
+ kind: "blocked",
126
+ })
127
+ const html = decodeEntities(placeholder(lifecycle))
128
+ // Nothing failed: the info tone and a polite status, not a danger alert.
129
+ expect(html).toContain('data-tone="info"')
130
+ expect(html).toContain('role="status"')
131
+ expect(html).not.toContain('role="alert"')
132
+ expect(html).toContain("lucide-info")
133
+ expect(html).toContain(en.blockedTitle)
134
+ expect(html).toContain(en.blockedHintNoRetry)
135
+ expect(html).not.toContain(en.errorTitle)
136
+ expect(html).not.toContain(en.errorHint)
137
+ expect(html).not.toContain("<button")
138
+ expect(html).not.toContain("<details")
139
+ const german = decodeEntities(placeholder(lifecycle, de))
140
+ expect(german).toContain(de.blockedTitle)
141
+ expect(german).toContain(de.blockedHintNoRetry)
142
+ expect(german).not.toContain("<button")
143
+ })
144
+
145
+ it("blocked, tool not reported as writing: the notice offers to load the view, in its own words", () => {
146
+ const lifecycle = {
147
+ phase: "error",
148
+ reason: undefined,
149
+ canRetry: true,
150
+ kind: "blocked",
151
+ } as const
152
+ const html = decodeEntities(placeholder(lifecycle))
153
+ expect(html).toContain(en.blockedTitle)
154
+ expect(html).toContain(en.blockedHint)
155
+ expect(html).not.toContain(en.blockedHintNoRetry)
156
+ expect(html).toContain(`${en.blockedRetry}</button>`)
157
+ expect(html).not.toContain(`${en.retry}</button>`)
158
+ expect(decodeEntities(placeholder(lifecycle, de))).toContain(`${de.blockedRetry}</button>`)
159
+ })
160
+
161
+ it("describeError tells a failure from a block, and can word either", () => {
162
+ const seen: unknown[] = []
163
+ const describeError: DescribeViewError = (reason, info) => {
164
+ seen.push([reason, info])
165
+ return info?.kind === "blocked" ? { description: "Ask the operator." } : undefined
166
+ }
167
+ const render = (lifecycle: Parameters<typeof ViewPlaceholder>[0]["lifecycle"]) =>
168
+ decodeEntities(
169
+ renderToStaticMarkup(
170
+ <ViewPlaceholder
171
+ lifecycle={lifecycle}
172
+ labels={en}
173
+ onRetry={noop}
174
+ describeError={describeError}
175
+ />,
176
+ ),
177
+ )
178
+ const blocked = render({ phase: "error", reason: undefined, canRetry: false, kind: "blocked" })
179
+ expect(blocked).toContain(en.blockedTitle)
180
+ expect(blocked).toContain("Ask the operator.")
181
+ expect(blocked).not.toContain(en.blockedHintNoRetry)
182
+ const failed = render({ phase: "error", reason: "HTTP 503", canRetry: true })
183
+ expect(failed).toContain(en.errorHint)
184
+ expect(seen).toEqual([
185
+ [undefined, { kind: "blocked" }],
186
+ ["HTTP 503", { kind: "failed" }],
187
+ ])
188
+ })
189
+
105
190
  it("cancelled: the cancellation notice, no skeleton, no retry", () => {
106
191
  const html = placeholder({ phase: "cancelled" })
107
192
  expect(html).toContain(en.cancelled)
@@ -208,6 +293,8 @@ describe("ViewToolbar", () => {
208
293
  expect(html).toContain("lucide-pencil")
209
294
  const edit = openingTags(html, "button")[0] ?? ""
210
295
  expectChromeButton(edit)
296
+ // The focus target the builder hands the focus back to when it closes.
297
+ expect(VIEW_FOCUS_TARGET).toBe(`[data-slot="${attribute(edit, "data-slot")}"]`)
211
298
  })
212
299
 
213
300
  it("titles the view once: show (configurable level), hide, or slot", () => {
@@ -238,6 +325,59 @@ describe("ViewToolbar", () => {
238
325
  expect(html).toContain(`aria-label="${de.refresh}"`)
239
326
  expect(html).toContain(`aria-label="${de.enterFullscreen}"`)
240
327
  })
328
+
329
+ it("stamps the time of data the view re-loaded itself, in words that carry its meaning (toolkit#176 N23)", () => {
330
+ expect(toolbar()).not.toContain('data-slot="view-data-as-of"')
331
+ const html = toolbar({ dataAsOf: "2:32 PM" })
332
+ const stamp = openingTags(html, "p").find(
333
+ (t) => attribute(t, "data-slot") === "view-data-as-of",
334
+ )
335
+ expect(stamp).toBeDefined()
336
+ expect(attribute(stamp ?? "", "class")).toContain("text-muted-foreground")
337
+ // Visible text only: no hover-only tooltip, no screen-reader-only extra
338
+ // (touch and keyboard users read exactly what everyone reads, once).
339
+ expect(attribute(stamp ?? "", "title")).toBeUndefined()
340
+ expect(html).not.toContain("sr-only")
341
+ expect(html).toContain(`>${en.dataAsOf("2:32 PM")}</p>`)
342
+ expect(en.dataAsOf("2:32 PM")).toBe("Reloaded at 2:32 PM")
343
+ expect(toolbar({ dataAsOf: "14:32", labels: de })).toContain(`>${de.dataAsOf("14:32")}</p>`)
344
+ })
345
+
346
+ it("puts the stamp under the title, never beside the controls, and the controls never shrink", () => {
347
+ const html = toolbar({ dataAsOf: "14:32", builderAvailable: true })
348
+ const bar = openingTags(html, "div")
349
+ // [toolbar, heading column, controls]: the stamp is the column's second line.
350
+ expect(bar.map((t) => attribute(t, "data-slot") ?? "")).toEqual([
351
+ "view-toolbar",
352
+ "view-heading",
353
+ "",
354
+ ])
355
+ expect(attribute(bar[1] ?? "", "class")).toBe("min-w-0 flex-1")
356
+ expect(html).toMatch(
357
+ /data-slot="view-heading"[^>]*><h2[^>]*>Process definitions<\/h2><p data-slot="view-data-as-of"/,
358
+ )
359
+ expect((attribute(bar[2] ?? "", "class") ?? "").split(" ")).toEqual(
360
+ expect.arrayContaining(["ml-auto", "shrink-0"]),
361
+ )
362
+ expect(attribute(bar[2] ?? "", "class")).not.toContain("min-w-0")
363
+ // Without a stamp the title sits straight in the row, as before.
364
+ const plain = toolbar({ builderAvailable: true })
365
+ expect(plain).not.toContain('data-slot="view-heading"')
366
+ expect(plain).toMatch(/data-slot="view-toolbar"[^>]*><h2/)
367
+ // The slot title takes the stamp under it as well.
368
+ const slot = toolbar({ titleMode: "slot", renderTitle: (t) => t, dataAsOf: "14:32" })
369
+ expect(slot).toMatch(/data-slot="view-heading"[^>]*><div[^>]*>Process definitions<\/div><p/)
370
+ })
371
+
372
+ it("keeps the bar for the stamp alone", () => {
373
+ const bare = {
374
+ canFullscreen: false,
375
+ titleMode: "hide" as const,
376
+ viewData: { ...view, _refreshParams: undefined },
377
+ }
378
+ expect(toolbar(bare)).toBe("")
379
+ expect(toolbar({ ...bare, dataAsOf: "14:32" })).toContain('data-slot="view-data-as-of"')
380
+ })
241
381
  })
242
382
 
243
383
  describe("toolbarControls / fullscreenAvailable", () => {
@@ -79,7 +79,7 @@ export function isCompleteViewData(value: unknown): value is ViewData {
79
79
  return Boolean(v?.context && v?.layout)
80
80
  }
81
81
 
82
- /** What the LayoutBuilder hands back on Done. */
82
+ /** What the LayoutBuilder hands back on Apply. */
83
83
  export interface BuilderCommit {
84
84
  layout: LayoutConfig
85
85
  keys?: Record<string, unknown>
@@ -135,6 +135,13 @@ export interface ViewToolbarProps {
135
135
  onRefresh: () => Promise<void>
136
136
  onEnterBuildMode: () => void
137
137
  onToggleFullscreen: () => Promise<void>
138
+ /**
139
+ * The pre-formatted time of a view that loaded its data again on its own
140
+ * (toolkit#176 N23): the toolbar stamps it in a quiet line under the title,
141
+ * so the figures read as live data of that time, not the result the chat
142
+ * answered from.
143
+ */
144
+ dataAsOf?: string
138
145
  }
139
146
 
140
147
  /** The toolbar title per {@link ViewTitleMode}: a heading, the consumer's slot, or nothing. */
@@ -158,6 +165,39 @@ function ToolbarTitle({
158
165
  )
159
166
  }
160
167
 
168
+ /**
169
+ * The title, and under it the stamp of data the view re-loaded itself. The
170
+ * stamp is a second line of the title column, never an item beside the
171
+ * controls: the title keeps the row's width (it is the user's orientation),
172
+ * and the stamp truncates only against its own column. Its words carry its
173
+ * meaning, so it needs no tooltip that touch and keyboard users never reach.
174
+ * Without a stamp the title renders as before, straight in the row.
175
+ */
176
+ function ToolbarHeading({
177
+ title,
178
+ mode,
179
+ level,
180
+ renderTitle,
181
+ stamp,
182
+ }: {
183
+ title: string | undefined
184
+ mode: ViewTitleMode
185
+ level: ViewTitleLevel
186
+ renderTitle?: (title: string | undefined) => ReactNode
187
+ stamp: string | undefined
188
+ }) {
189
+ const heading = <ToolbarTitle title={title} mode={mode} level={level} renderTitle={renderTitle} />
190
+ if (!stamp) return heading
191
+ return (
192
+ <div data-slot="view-heading" className="min-w-0 flex-1">
193
+ {heading}
194
+ <p data-slot="view-data-as-of" className="text-muted-foreground truncate text-sm">
195
+ {stamp}
196
+ </p>
197
+ </div>
198
+ )
199
+ }
200
+
161
201
  /** Which toolbar controls render — pure, so the gates are unit-testable. */
162
202
  export function toolbarControls(input: {
163
203
  viewData: Pick<ViewData, "_refreshParams" | "layout" | "title">
@@ -250,7 +290,9 @@ function FullscreenButton({
250
290
  * `aria-label`; Fullscreen renders only when the host offers it. All three are
251
291
  * chrome buttons in the CI pattern (CI §8: surface, border, shadow-1, blue
252
292
  * hover; {@link CHROME_BUTTON}). A 16 px inset keeps the bar off the frame
253
- * edge, also in fullscreen.
293
+ * edge, also in fullscreen. The controls never shrink; the stamp of re-loaded
294
+ * data sits under the title ({@link ToolbarHeading}), so neither the title
295
+ * nor the stamp competes with the controls for width.
254
296
  */
255
297
  export function ViewToolbar({
256
298
  hidden,
@@ -266,21 +308,29 @@ export function ViewToolbar({
266
308
  onRefresh,
267
309
  onEnterBuildMode,
268
310
  onToggleFullscreen,
311
+ dataAsOf,
269
312
  }: ViewToolbarProps) {
270
313
  const show = toolbarControls({ viewData, builderAvailable, canFullscreen, titleMode })
271
- if (hidden || !show.any) return null
314
+ if (hidden || (!show.any && !dataAsOf)) return null
272
315
  return (
273
316
  <LucideProvider strokeWidth={DENSE_ICON_STROKE}>
274
317
  <div data-slot="view-toolbar" className="flex min-h-12 items-center gap-2 px-4 py-2">
275
- <ToolbarTitle
318
+ <ToolbarHeading
276
319
  title={viewData.title}
277
320
  mode={titleMode}
278
321
  level={titleLevel}
279
322
  renderTitle={renderTitle}
323
+ stamp={dataAsOf ? labels.dataAsOf(dataAsOf) : undefined}
280
324
  />
281
325
  <div className="ml-auto flex shrink-0 items-center gap-1">
282
326
  {show.edit && (
283
- <Button type="button" {...CHROME_BUTTON} size="sm" onClick={onEnterBuildMode}>
327
+ <Button
328
+ type="button"
329
+ {...CHROME_BUTTON}
330
+ size="sm"
331
+ data-slot="view-edit"
332
+ onClick={onEnterBuildMode}
333
+ >
284
334
  <Pencil aria-hidden="true" />
285
335
  {labels.build}
286
336
  </Button>
@@ -354,7 +404,7 @@ export function StepErrors({
354
404
  {human ? (
355
405
  <span className="font-medium">{human}</span>
356
406
  ) : (
357
- <span className="font-mono text-xs">{ref.step ?? ref.id}</span>
407
+ <span className="text-small font-mono">{ref.step ?? ref.id}</span>
358
408
  )}
359
409
  </li>
360
410
  ))}
@@ -2,17 +2,27 @@
2
2
  * The pre-payload states of `McpAppView`, one per lifecycle phase
3
3
  * (`view-lifecycle.ts`): waiting (skeleton + status line + slow hint),
4
4
  * cancelled, and error (icon, title, what to do, raw reason under "Details",
5
- * retry when the originating tool is known). Hook-free and SSR-renderable, so
6
- * each phase is pinned by a test.
5
+ * retry when the originating tool is known). A `blocked` error is a notice in
6
+ * the info tone, not a failure: the tool ran, but the chat did not pass its
7
+ * result to the view. Hook-free and SSR-renderable, so each phase is pinned
8
+ * by a test.
7
9
  */
8
- import { ErrorState } from "../components/ErrorState.js"
10
+ import { ErrorState, type ErrorStateTone } from "../components/ErrorState.js"
9
11
  import { Skeleton } from "../primitives/skeleton.js"
10
12
  import type { McpAppViewLabels } from "./mcp-app-view-chrome.js"
11
- import type { ViewLifecycle } from "./view-lifecycle.js"
13
+ import type { ViewErrorKind, ViewLifecycle } from "./view-lifecycle.js"
12
14
 
13
- /** Human text for a raw error reason (e.g. map "503" to "the engine is down"). */
15
+ export type { ViewErrorKind } from "./view-lifecycle.js"
16
+
17
+ /**
18
+ * Human text for a raw error reason (e.g. map "503" to "the engine is down").
19
+ * `info.kind` tells a failure (`"failed"`) from a result the chat did not pass
20
+ * to the view (`"blocked"`, no reason); return `undefined` to keep the
21
+ * toolkit's words for that state.
22
+ */
14
23
  export type DescribeViewError = (
15
24
  reason: string | undefined,
25
+ info?: { kind: ViewErrorKind },
16
26
  ) => { title?: string; description?: string } | undefined
17
27
 
18
28
  export function ViewPlaceholder({
@@ -41,6 +51,7 @@ export function ViewPlaceholder({
41
51
  case "error":
42
52
  return (
43
53
  <ViewErrorState
54
+ kind={lifecycle.kind}
44
55
  reason={lifecycle.reason}
45
56
  canRetry={lifecycle.canRetry}
46
57
  labels={labels}
@@ -69,30 +80,59 @@ function ViewWaiting({ slow, labels }: { slow: boolean; labels: Required<McpAppV
69
80
  )
70
81
  }
71
82
 
83
+ /** The words and tone of an error phase; the hint never promises a retry the state does not offer. */
84
+ function errorStateCopy(
85
+ kind: ViewErrorKind,
86
+ canRetry: boolean,
87
+ labels: Required<McpAppViewLabels>,
88
+ ): { tone: ErrorStateTone; role: "alert" | "status"; title: string; hint: string; retry: string } {
89
+ if (kind === "blocked") {
90
+ // Nothing failed: a quiet notice that says what happened and what to do.
91
+ return {
92
+ tone: "info",
93
+ role: "status",
94
+ title: labels.blockedTitle,
95
+ hint: canRetry ? labels.blockedHint : labels.blockedHintNoRetry,
96
+ retry: labels.blockedRetry,
97
+ }
98
+ }
99
+ return {
100
+ tone: "danger",
101
+ role: "alert",
102
+ title: labels.errorTitle,
103
+ hint: canRetry ? labels.errorHint : labels.errorHintNoRetry,
104
+ retry: labels.retry,
105
+ }
106
+ }
107
+
72
108
  function ViewErrorState({
109
+ kind = "failed",
73
110
  reason,
74
111
  canRetry,
75
112
  labels,
76
113
  onRetry,
77
114
  describeError,
78
115
  }: {
116
+ /** Absent for a failure (see `ViewLifecycle`). */
117
+ kind: ViewErrorKind | undefined
79
118
  reason: string | undefined
80
119
  canRetry: boolean
81
120
  labels: Required<McpAppViewLabels>
82
121
  onRetry: () => void
83
122
  describeError?: DescribeViewError
84
123
  }) {
85
- const human = describeError?.(reason)
86
- // The hint never promises a retry the state does not offer.
87
- const hint = canRetry ? labels.errorHint : labels.errorHintNoRetry
124
+ const human = describeError?.(reason, { kind })
125
+ const copy = errorStateCopy(kind, canRetry, labels)
88
126
  return (
89
127
  <div className="px-4 py-4">
90
128
  <ErrorState
91
- title={human?.title ?? labels.errorTitle}
92
- description={human?.description ?? hint}
129
+ tone={copy.tone}
130
+ role={copy.role}
131
+ title={human?.title ?? copy.title}
132
+ description={human?.description ?? copy.hint}
93
133
  details={reason}
94
134
  detailsLabel={labels.errorDetails}
95
- retryLabel={labels.retry}
135
+ retryLabel={copy.retry}
96
136
  onRetry={canRetry ? onRetry : undefined}
97
137
  />
98
138
  </div>
@@ -0,0 +1,292 @@
1
+ // @vitest-environment happy-dom
2
+ import { ToolError } from "mcp-use/react"
3
+ import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest"
4
+ import { createToolResultObserver, type ToolResultObserver } from "../lib/tool-result-observer.js"
5
+ import { TOOLKIT_MESSAGES } from "../locale/toolkit-messages.js"
6
+ import { advanceTimers, inAct, renderWithEffects, type EffectRender } from "../render-effects.js"
7
+ import { createStandaloneHostBridge, HostBridgeProvider } from "./host-bridge.js"
8
+ import { McpAppView } from "./mcp-app-view.js"
9
+ import type { BuilderCommit, ViewData } from "./mcp-app-view-chrome.js"
10
+
11
+ /**
12
+ * McpAppView's lifecycle with its effects running (toolkit#176 K13, N23): the
13
+ * view takes the document's tool-result observer by default, so a slow tool
14
+ * on a compliant host is never re-run and a stripped result re-runs from the
15
+ * observer's notification alone; and a payload the view re-loaded itself (a
16
+ * recovery, a retry after a result the chat got, a refresh, an Apply from
17
+ * the view builder) carries the data-as-of stamp and its model-context line.
18
+ * The SSR suite
19
+ * (`mcp-app-view.test.tsx`) pins every state's markup.
20
+ */
21
+
22
+ const host = vi.hoisted(
23
+ (): { tool: Record<string, unknown>; observer: ToolResultObserver | null } => ({
24
+ tool: {},
25
+ observer: null,
26
+ }),
27
+ )
28
+
29
+ /** The commit the stubbed builder hands back on Apply (`toExitCommit` in the real one). */
30
+ const builder = vi.hoisted((): { commit: BuilderCommit | undefined } => ({ commit: undefined }))
31
+
32
+ // The builder's own UI is not this suite's subject: a stub hands McpAppView
33
+ // the commit Apply would, so the test drives only the view's wiring of it.
34
+ vi.mock(import("./layout-builder.js"), async (importOriginal) => {
35
+ const actual = await importOriginal()
36
+ const { createElement } = await import("react")
37
+ const LayoutBuilder: typeof actual.LayoutBuilder = ({ onExit }) =>
38
+ createElement("button", { type: "button", onClick: () => onExit?.(builder.commit) }, "Apply")
39
+ return { ...actual, LayoutBuilder }
40
+ })
41
+
42
+ vi.mock(import("../lib/tool-result-observer.js"), async (importOriginal) => {
43
+ const actual = await importOriginal()
44
+ return { ...actual, installToolResultObserver: () => host.observer }
45
+ })
46
+
47
+ vi.mock(import("mcp-use/react"), async (importOriginal) => {
48
+ const actual = await importOriginal()
49
+ const context = {
50
+ locale: "en-US",
51
+ timeZone: "UTC",
52
+ safeArea: { top: 0, right: 0, bottom: 0, left: 0 },
53
+ hostContext: { toolInfo: { tool: { name: "render-view" } } },
54
+ }
55
+ const displayMode = {
56
+ displayMode: "inline",
57
+ availableDisplayModes: ["inline"],
58
+ requestDisplayMode: () => Promise.resolve(),
59
+ }
60
+ return {
61
+ ...actual,
62
+ useToolContext: (() => host.tool) as unknown as typeof actual.useToolContext,
63
+ useHostContext: (() => context) as unknown as typeof actual.useHostContext,
64
+ useDisplayMode: (() => displayMode) as unknown as typeof actual.useDisplayMode,
65
+ }
66
+ })
67
+
68
+ const en = TOOLKIT_MESSAGES.en.view
69
+ const NOW = Date.UTC(2026, 9, 11, 14, 32)
70
+
71
+ const VIEW: ViewData = {
72
+ title: "Process definitions",
73
+ context: { keys: {}, stepIds: ["kpi"], stepData: {}, errors: [] },
74
+ layout: { rows: [{ row: [{ widget: "demo:probe" }] }] },
75
+ _refreshParams: { layout: { rows: [] }, steps: [{ id: "kpi", step: "demo:kpis" }] },
76
+ }
77
+
78
+ /** A host channel the observer listens on, with the SEP-1865 messages a host sends. */
79
+ function fakeHost() {
80
+ const channel = new EventTarget()
81
+ host.observer = createToolResultObserver(channel)
82
+ const send = (data: unknown) =>
83
+ inAct(() => channel.dispatchEvent(new MessageEvent("message", { data })))
84
+ const notify = (method: string, params: Record<string, unknown>) =>
85
+ send({ jsonrpc: "2.0", method, params })
86
+ return {
87
+ handshake: () =>
88
+ send({
89
+ jsonrpc: "2.0",
90
+ id: 1,
91
+ result: { protocolVersion: "2026-01-26", hostCapabilities: {} },
92
+ }),
93
+ toolInput: (args: Record<string, unknown>) =>
94
+ notify("ui/notifications/tool-input", { arguments: args }),
95
+ strippedResult: () =>
96
+ notify("ui/notifications/tool-result", { content: [{ type: "text", text: "Rendered." }] }),
97
+ structuredResult: (structuredContent: unknown) =>
98
+ notify("ui/notifications/tool-result", { content: [], structuredContent }),
99
+ }
100
+ }
101
+
102
+ type CallTool = (name: string, args: Record<string, unknown>) => Promise<unknown>
103
+
104
+ let callTool: Mock<CallTool>
105
+ let modelContext: string[]
106
+ let mounted: EffectRender | null = null
107
+
108
+ async function render() {
109
+ const bridge = createStandaloneHostBridge({
110
+ callTool: (name, args) => callTool(name, args),
111
+ onModelContext: (text) => modelContext.push(text),
112
+ })
113
+ mounted = await renderWithEffects(
114
+ <HostBridgeProvider bridge={bridge}>
115
+ <McpAppView widgets={{ "demo:probe": () => <span>probe</span> }} />
116
+ </HostBridgeProvider>,
117
+ )
118
+ return mounted
119
+ }
120
+
121
+ const stamp = (view: EffectRender) =>
122
+ view.container.querySelector('[data-slot="view-data-as-of"]')?.textContent ?? null
123
+
124
+ const button = (view: EffectRender, name: string): HTMLElement => {
125
+ const found = [...view.container.querySelectorAll("button")].find(
126
+ (b) => b.textContent === name || b.getAttribute("aria-label") === name,
127
+ )
128
+ if (!found) throw new Error(`no button "${name}"`)
129
+ return found
130
+ }
131
+
132
+ const click = (target: HTMLElement) => inAct(() => target.click())
133
+
134
+ const toolCalls = (name: string) => callTool.mock.calls.filter(([called]) => called === name)
135
+
136
+ beforeEach(() => {
137
+ builder.commit = undefined
138
+ vi.useFakeTimers()
139
+ vi.setSystemTime(NOW)
140
+ host.tool = { status: "pending", toolInput: { id: 7 }, toolOutput: undefined }
141
+ host.observer = null
142
+ modelContext = []
143
+ callTool = vi.fn<CallTool>(() => Promise.resolve({ structuredContent: VIEW }))
144
+ })
145
+
146
+ afterEach(async () => {
147
+ await mounted?.unmount()
148
+ mounted = null
149
+ vi.useRealTimers()
150
+ })
151
+
152
+ describe("McpAppView recovery with its effects (K13)", () => {
153
+ it("keep: a delivered view is never re-run", async () => {
154
+ const channel = fakeHost()
155
+ await channel.handshake()
156
+ await channel.toolInput({ id: 7 })
157
+ await channel.structuredResult(VIEW)
158
+ host.tool = { status: "ready", toolInput: { id: 7 }, toolOutput: VIEW }
159
+ const view = await render()
160
+ await advanceTimers(vi.advanceTimersByTime, 10_000)
161
+ expect(callTool).not.toHaveBeenCalled()
162
+ expect(view.container.querySelector('[data-slot="view-toolbar"]')).not.toBeNull()
163
+ expect(stamp(view), "the host's own payload").toBeNull()
164
+ })
165
+
166
+ it("slow: a compliant host still answering keeps the view waiting, never re-run", async () => {
167
+ const channel = fakeHost()
168
+ await channel.handshake()
169
+ await channel.toolInput({ id: 7 })
170
+ const view = await render()
171
+ await advanceTimers(vi.advanceTimersByTime, 2_501)
172
+ await advanceTimers(vi.advanceTimersByTime, 10_000)
173
+ expect(callTool).not.toHaveBeenCalled()
174
+ expect(view.container.querySelector('[data-slot="skeleton"]')).not.toBeNull()
175
+ })
176
+
177
+ it("strip: the observer's notification alone re-runs the call once, with its complete input, and stamps it", async () => {
178
+ const channel = fakeHost()
179
+ await channel.handshake()
180
+ const view = await render()
181
+ await channel.toolInput({ id: 7, page: 2 })
182
+ await channel.strippedResult()
183
+ expect(callTool).toHaveBeenCalledTimes(1)
184
+ expect(callTool).toHaveBeenCalledWith("render-view", { id: 7, page: 2 })
185
+ expect(stamp(view)).toBe("Reloaded at 2:32 PM")
186
+ expect(modelContext.at(-1)).toMatch(
187
+ /^The view of render-view re-ran the tool at 2026-10-11T14:32:00\.000Z because the host did not pass/,
188
+ )
189
+ })
190
+ })
191
+
192
+ describe("McpAppView data-as-of stamp on a user's re-run (N23)", () => {
193
+ it("a retry after a failed recovery is stamped: the chat answered from the original call", async () => {
194
+ const channel = fakeHost()
195
+ await channel.handshake()
196
+ callTool.mockRejectedValueOnce(new Error("503"))
197
+ const view = await render()
198
+ await channel.toolInput({ id: 7 })
199
+ await channel.strippedResult()
200
+ expect(view.container.querySelector('[role="alert"]')).not.toBeNull()
201
+ vi.setSystemTime(NOW + 60_000)
202
+ await click(button(view, en.retry))
203
+ expect(toolCalls("render-view")).toHaveLength(2)
204
+ expect(stamp(view)).toBe("Reloaded at 2:33 PM")
205
+ expect(modelContext.at(-1)).toMatch(/re-ran the tool at 2026-10-11T14:33:00\.000Z/)
206
+ })
207
+
208
+ it("a retry after a failed call is not: the model never had figures", async () => {
209
+ host.tool = {
210
+ status: "error",
211
+ error: new ToolError({ isError: true, content: [{ type: "text", text: "[503] down" }] }),
212
+ toolInput: { id: 7 },
213
+ }
214
+ const view = await render()
215
+ await click(button(view, en.retry))
216
+ expect(toolCalls("render-view")).toHaveLength(1)
217
+ expect(view.container.querySelector('[data-slot="view-toolbar"]')).not.toBeNull()
218
+ expect(stamp(view)).toBeNull()
219
+ expect(modelContext.some((line) => line.includes("re-ran the tool"))).toBe(false)
220
+ })
221
+
222
+ it("a refresh is stamped and told to the model as the user's refresh", async () => {
223
+ host.tool = { status: "ready", toolInput: { id: 7 }, toolOutput: VIEW }
224
+ const view = await render()
225
+ expect(stamp(view)).toBeNull()
226
+ vi.setSystemTime(NOW + 38 * 60_000)
227
+ await click(button(view, en.refresh))
228
+ expect(toolCalls("refresh-view")).toEqual([["refresh-view", VIEW._refreshParams]])
229
+ expect(stamp(view)).toBe("Reloaded at 3:10 PM")
230
+ expect(modelContext.at(-1)).toBe(
231
+ "The view of render-view was refreshed by the user at 2026-10-11T15:10:00.000Z; its figures " +
232
+ "are live as of then and can differ from the original result.",
233
+ )
234
+ })
235
+ })
236
+
237
+ describe("McpAppView data-as-of stamp on a builder Apply (N23)", () => {
238
+ /** The view as a builder-enabled server delivers it. */
239
+ const EDITABLE: ViewData = { ...VIEW, builderAvailable: true }
240
+ /** The builder's live context: its catalogue call ran the steps again. */
241
+ const LIVE: ViewData["context"] = {
242
+ keys: { region: "north" },
243
+ stepIds: ["kpi"],
244
+ stepData: {
245
+ kpi: { data: { open: 12 }, keys: {}, _app: "demo", _dataType: "demo:kpis" },
246
+ },
247
+ errors: [],
248
+ }
249
+ const edit = async (view: EffectRender, commit: BuilderCommit) => {
250
+ builder.commit = commit
251
+ await click(button(view, en.build))
252
+ await click(button(view, "Apply"))
253
+ }
254
+
255
+ it("an Apply with the builder's live figures is stamped and told to the model as the user's edit", async () => {
256
+ host.tool = { status: "ready", toolInput: { id: 7 }, toolOutput: EDITABLE }
257
+ const view = await render()
258
+ expect(stamp(view), "the host's own payload").toBeNull()
259
+ vi.setSystemTime(NOW + 5 * 60_000)
260
+ await edit(view, { layout: EDITABLE.layout!, context: LIVE })
261
+ expect(stamp(view)).toBe("Reloaded at 2:37 PM")
262
+ expect(modelContext.at(-1)).toBe(
263
+ "The view of render-view was edited by the user in the view builder, which loaded its data " +
264
+ "again at 2026-10-11T14:37:00.000Z; its figures are live as of then and can differ from " +
265
+ "the original result.",
266
+ )
267
+ })
268
+
269
+ it("an Apply after a refresh moves the stamp to the builder's figures", async () => {
270
+ host.tool = { status: "ready", toolInput: { id: 7 }, toolOutput: EDITABLE }
271
+ callTool.mockResolvedValue({ structuredContent: EDITABLE })
272
+ const view = await render()
273
+ vi.setSystemTime(NOW + 60_000)
274
+ await click(button(view, en.refresh))
275
+ expect(stamp(view)).toBe("Reloaded at 2:33 PM")
276
+ vi.setSystemTime(NOW + 5 * 60_000)
277
+ await edit(view, { layout: EDITABLE.layout!, context: LIVE })
278
+ expect(stamp(view), "the stamp of the refresh above the builder's figures").toBe(
279
+ "Reloaded at 2:37 PM",
280
+ )
281
+ })
282
+
283
+ it("an Apply without a context keeps the shown figures, and with them their stamp", async () => {
284
+ host.tool = { status: "ready", toolInput: { id: 7 }, toolOutput: EDITABLE }
285
+ const view = await render()
286
+ vi.setSystemTime(NOW + 5 * 60_000)
287
+ await edit(view, { layout: EDITABLE.layout!, title: "Renamed" })
288
+ expect(view.container.textContent).toContain("Renamed")
289
+ expect(stamp(view)).toBeNull()
290
+ expect(modelContext.some((line) => line.includes("view builder"))).toBe(false)
291
+ })
292
+ })