@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.
- package/README.md +7 -4
- package/dist/app/adapt-data-widget.d.ts +1 -22
- package/dist/app/adapt-data-widget.d.ts.map +1 -1
- package/dist/app/adapt-data-widget.js +1 -35
- package/dist/app/adapt-data-widget.js.map +1 -1
- package/dist/app/builder/BuilderBanners.js +1 -1
- package/dist/app/builder/BuilderBanners.js.map +1 -1
- package/dist/app/builder/CanvasRow.d.ts +4 -2
- package/dist/app/builder/CanvasRow.d.ts.map +1 -1
- package/dist/app/builder/CanvasRow.js +21 -7
- package/dist/app/builder/CanvasRow.js.map +1 -1
- package/dist/app/builder/CatalogueSheet.d.ts.map +1 -1
- package/dist/app/builder/CatalogueSheet.js +11 -4
- package/dist/app/builder/CatalogueSheet.js.map +1 -1
- package/dist/app/builder/CellPropsSheet.d.ts +7 -3
- package/dist/app/builder/CellPropsSheet.d.ts.map +1 -1
- package/dist/app/builder/CellPropsSheet.js +10 -3
- package/dist/app/builder/CellPropsSheet.js.map +1 -1
- package/dist/app/builder/DiscardDialog.d.ts +18 -0
- package/dist/app/builder/DiscardDialog.d.ts.map +1 -0
- package/dist/app/builder/DiscardDialog.js +18 -0
- package/dist/app/builder/DiscardDialog.js.map +1 -0
- package/dist/app/builder/LayoutSurface.d.ts +33 -9
- package/dist/app/builder/LayoutSurface.d.ts.map +1 -1
- package/dist/app/builder/LayoutSurface.js +74 -23
- package/dist/app/builder/LayoutSurface.js.map +1 -1
- package/dist/app/builder/PipelineStrip.d.ts +2 -1
- package/dist/app/builder/PipelineStrip.d.ts.map +1 -1
- package/dist/app/builder/PipelineStrip.js +26 -13
- package/dist/app/builder/PipelineStrip.js.map +1 -1
- package/dist/app/builder/SaveDialog.d.ts +9 -1
- package/dist/app/builder/SaveDialog.d.ts.map +1 -1
- package/dist/app/builder/SaveDialog.js +8 -2
- package/dist/app/builder/SaveDialog.js.map +1 -1
- package/dist/app/builder/Toolbar.d.ts +12 -4
- package/dist/app/builder/Toolbar.d.ts.map +1 -1
- package/dist/app/builder/Toolbar.js +18 -7
- package/dist/app/builder/Toolbar.js.map +1 -1
- package/dist/app/builder/Workspace.d.ts +23 -3
- package/dist/app/builder/Workspace.d.ts.map +1 -1
- package/dist/app/builder/Workspace.js +58 -8
- package/dist/app/builder/Workspace.js.map +1 -1
- package/dist/app/builder/builder-model.d.ts +143 -1
- package/dist/app/builder/builder-model.d.ts.map +1 -1
- package/dist/app/builder/builder-model.js +191 -1
- package/dist/app/builder/builder-model.js.map +1 -1
- package/dist/app/builder/reveal.d.ts +10 -0
- package/dist/app/builder/reveal.d.ts.map +1 -0
- package/dist/app/builder/reveal.js +10 -0
- package/dist/app/builder/reveal.js.map +1 -0
- package/dist/app/builder/use-builder-catalogue.d.ts +4 -1
- package/dist/app/builder/use-builder-catalogue.d.ts.map +1 -1
- package/dist/app/builder/use-builder-catalogue.js +44 -17
- package/dist/app/builder/use-builder-catalogue.js.map +1 -1
- package/dist/app/builder/use-builder-session.d.ts +30 -0
- package/dist/app/builder/use-builder-session.d.ts.map +1 -0
- package/dist/app/builder/use-builder-session.js +58 -0
- package/dist/app/builder/use-builder-session.js.map +1 -0
- package/dist/app/builder/use-display-resolver.d.ts +17 -0
- package/dist/app/builder/use-display-resolver.d.ts.map +1 -0
- package/dist/app/builder/use-display-resolver.js +16 -0
- package/dist/app/builder/use-display-resolver.js.map +1 -0
- package/dist/app/index.d.ts +8 -3
- package/dist/app/index.d.ts.map +1 -1
- package/dist/app/index.js +8 -2
- package/dist/app/index.js.map +1 -1
- package/dist/app/layout-builder.d.ts +13 -25
- package/dist/app/layout-builder.d.ts.map +1 -1
- package/dist/app/layout-builder.js +75 -81
- package/dist/app/layout-builder.js.map +1 -1
- package/dist/app/mcp-app-view-chrome.d.ts +12 -3
- package/dist/app/mcp-app-view-chrome.d.ts.map +1 -1
- package/dist/app/mcp-app-view-chrome.js +21 -5
- package/dist/app/mcp-app-view-chrome.js.map +1 -1
- package/dist/app/mcp-app-view-states.d.ts +11 -3
- package/dist/app/mcp-app-view-states.d.ts.map +1 -1
- package/dist/app/mcp-app-view-states.js +29 -8
- package/dist/app/mcp-app-view-states.js.map +1 -1
- package/dist/app/mcp-app-view.d.ts +33 -1
- package/dist/app/mcp-app-view.d.ts.map +1 -1
- package/dist/app/mcp-app-view.js +181 -127
- package/dist/app/mcp-app-view.js.map +1 -1
- package/dist/app/mcp-toolkit-app.d.ts +6 -1
- package/dist/app/mcp-toolkit-app.d.ts.map +1 -1
- package/dist/app/mcp-toolkit-app.js +8 -1
- package/dist/app/mcp-toolkit-app.js.map +1 -1
- package/dist/app/mcp-use-host-bridge.d.ts +15 -6
- package/dist/app/mcp-use-host-bridge.d.ts.map +1 -1
- package/dist/app/mcp-use-host-bridge.js +51 -22
- package/dist/app/mcp-use-host-bridge.js.map +1 -1
- package/dist/app/model-context.d.ts +71 -0
- package/dist/app/model-context.d.ts.map +1 -0
- package/dist/app/model-context.js +101 -0
- package/dist/app/model-context.js.map +1 -0
- package/dist/app/use-build-mode-focus.d.ts +33 -0
- package/dist/app/use-build-mode-focus.d.ts.map +1 -0
- package/dist/app/use-build-mode-focus.js +59 -0
- package/dist/app/use-build-mode-focus.js.map +1 -0
- package/dist/app/use-view-actions.d.ts +31 -0
- package/dist/app/use-view-actions.d.ts.map +1 -0
- package/dist/app/use-view-actions.js +81 -0
- package/dist/app/use-view-actions.js.map +1 -0
- package/dist/app/use-view-lifecycle.d.ts +4 -0
- package/dist/app/use-view-lifecycle.d.ts.map +1 -1
- package/dist/app/use-view-lifecycle.js +2 -0
- package/dist/app/use-view-lifecycle.js.map +1 -1
- package/dist/app/view-lifecycle.d.ts +80 -4
- package/dist/app/view-lifecycle.d.ts.map +1 -1
- package/dist/app/view-lifecycle.js +90 -3
- package/dist/app/view-lifecycle.js.map +1 -1
- package/dist/app/widget-fixture.d.ts +10 -3
- package/dist/app/widget-fixture.d.ts.map +1 -1
- package/dist/app/widget-fixture.js +21 -8
- package/dist/app/widget-fixture.js.map +1 -1
- package/dist/app/widget-props-sheet.d.ts +3 -1
- package/dist/app/widget-props-sheet.d.ts.map +1 -1
- package/dist/app/widget-props-sheet.js +22 -10
- package/dist/app/widget-props-sheet.js.map +1 -1
- package/dist/app/widget-renderer.js +1 -1
- package/dist/app/widget-renderer.js.map +1 -1
- package/dist/components/DrillButton.d.ts +9 -6
- package/dist/components/DrillButton.d.ts.map +1 -1
- package/dist/components/DrillButton.js +8 -6
- package/dist/components/DrillButton.js.map +1 -1
- package/dist/components/EmptyState.d.ts.map +1 -1
- package/dist/components/EmptyState.js +4 -2
- package/dist/components/EmptyState.js.map +1 -1
- package/dist/components/ErrorState.d.ts +5 -2
- package/dist/components/ErrorState.d.ts.map +1 -1
- package/dist/components/ErrorState.js +25 -8
- package/dist/components/ErrorState.js.map +1 -1
- package/dist/components/FilterBar.d.ts +15 -4
- package/dist/components/FilterBar.d.ts.map +1 -1
- package/dist/components/FilterBar.js +11 -6
- package/dist/components/FilterBar.js.map +1 -1
- package/dist/components/KpiGrid.d.ts +66 -21
- package/dist/components/KpiGrid.d.ts.map +1 -1
- package/dist/components/KpiGrid.js +101 -34
- package/dist/components/KpiGrid.js.map +1 -1
- package/dist/components/ListFooter.d.ts +31 -4
- package/dist/components/ListFooter.d.ts.map +1 -1
- package/dist/components/ListFooter.js +30 -11
- package/dist/components/ListFooter.js.map +1 -1
- package/dist/components/PagedListFooter.d.ts +57 -0
- package/dist/components/PagedListFooter.d.ts.map +1 -0
- package/dist/components/PagedListFooter.js +27 -0
- package/dist/components/PagedListFooter.js.map +1 -0
- package/dist/components/Pills.d.ts +27 -7
- package/dist/components/Pills.d.ts.map +1 -1
- package/dist/components/Pills.js +21 -6
- package/dist/components/Pills.js.map +1 -1
- package/dist/components/SectionHeading.d.ts +3 -1
- package/dist/components/SectionHeading.d.ts.map +1 -1
- package/dist/components/SectionHeading.js +6 -2
- package/dist/components/SectionHeading.js.map +1 -1
- package/dist/components/Toast.d.ts +9 -3
- package/dist/components/Toast.d.ts.map +1 -1
- package/dist/components/Toast.js +31 -16
- package/dist/components/Toast.js.map +1 -1
- package/dist/components/WidgetHeader.d.ts +34 -10
- package/dist/components/WidgetHeader.d.ts.map +1 -1
- package/dist/components/WidgetHeader.js +11 -6
- package/dist/components/WidgetHeader.js.map +1 -1
- package/dist/hooks/index.d.ts +4 -4
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +1 -1
- package/dist/hooks/index.js.map +1 -1
- package/dist/hooks/tool-result-recovery-model.d.ts +155 -0
- package/dist/hooks/tool-result-recovery-model.d.ts.map +1 -0
- package/dist/hooks/tool-result-recovery-model.js +273 -0
- package/dist/hooks/tool-result-recovery-model.js.map +1 -0
- package/dist/hooks/use-tool-query.d.ts +56 -14
- package/dist/hooks/use-tool-query.d.ts.map +1 -1
- package/dist/hooks/use-tool-query.js +102 -25
- package/dist/hooks/use-tool-query.js.map +1 -1
- package/dist/hooks/use-tool-result-recovery.d.ts +47 -52
- package/dist/hooks/use-tool-result-recovery.d.ts.map +1 -1
- package/dist/hooks/use-tool-result-recovery.js +58 -120
- package/dist/hooks/use-tool-result-recovery.js.map +1 -1
- package/dist/hooks/use-view-data.d.ts +52 -10
- package/dist/hooks/use-view-data.d.ts.map +1 -1
- package/dist/hooks/use-view-data.js +81 -16
- package/dist/hooks/use-view-data.js.map +1 -1
- package/dist/hooks/use-view-tool-query.d.ts +12 -7
- package/dist/hooks/use-view-tool-query.d.ts.map +1 -1
- package/dist/hooks/use-view-tool-query.js +15 -14
- package/dist/hooks/use-view-tool-query.js.map +1 -1
- package/dist/index.d.ts +17 -10
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +18 -6
- package/dist/index.js.map +1 -1
- package/dist/lib/focus-return.d.ts +61 -0
- package/dist/lib/focus-return.d.ts.map +1 -0
- package/dist/lib/focus-return.js +46 -0
- package/dist/lib/focus-return.js.map +1 -0
- package/dist/lib/parse-tool-result.d.ts +23 -1
- package/dist/lib/parse-tool-result.d.ts.map +1 -1
- package/dist/lib/parse-tool-result.js +38 -2
- package/dist/lib/parse-tool-result.js.map +1 -1
- package/dist/lib/toast-model.d.ts +41 -1
- package/dist/lib/toast-model.d.ts.map +1 -1
- package/dist/lib/toast-model.js +67 -1
- package/dist/lib/toast-model.js.map +1 -1
- package/dist/lib/tone-utils.d.ts +58 -6
- package/dist/lib/tone-utils.d.ts.map +1 -1
- package/dist/lib/tone-utils.js +66 -4
- package/dist/lib/tone-utils.js.map +1 -1
- package/dist/lib/tool-query.d.ts +107 -0
- package/dist/lib/tool-query.d.ts.map +1 -0
- package/dist/lib/tool-query.js +182 -0
- package/dist/lib/tool-query.js.map +1 -0
- package/dist/lib/tool-result-observer.d.ts +108 -0
- package/dist/lib/tool-result-observer.d.ts.map +1 -0
- package/dist/lib/tool-result-observer.js +158 -0
- package/dist/lib/tool-result-observer.js.map +1 -0
- package/dist/lib/typography.d.ts +37 -0
- package/dist/lib/typography.d.ts.map +1 -0
- package/dist/lib/typography.js +41 -0
- package/dist/lib/typography.js.map +1 -0
- package/dist/lib/utils.d.ts.map +1 -1
- package/dist/lib/utils.js +11 -1
- package/dist/lib/utils.js.map +1 -1
- package/dist/locale/index.d.ts +1 -1
- package/dist/locale/index.d.ts.map +1 -1
- package/dist/locale/message-types.d.ts +117 -4
- package/dist/locale/message-types.d.ts.map +1 -1
- package/dist/locale/messages-de.d.ts.map +1 -1
- package/dist/locale/messages-de.js +44 -10
- package/dist/locale/messages-de.js.map +1 -1
- package/dist/locale/messages-en.d.ts.map +1 -1
- package/dist/locale/messages-en.js +44 -10
- package/dist/locale/messages-en.js.map +1 -1
- package/dist/locale/toolkit-messages.d.ts +19 -1
- package/dist/locale/toolkit-messages.d.ts.map +1 -1
- package/dist/locale/toolkit-messages.js +54 -1
- package/dist/locale/toolkit-messages.js.map +1 -1
- package/dist/primitives/alert.d.ts +1 -1
- package/dist/primitives/alert.d.ts.map +1 -1
- package/dist/primitives/alert.js +2 -0
- package/dist/primitives/alert.js.map +1 -1
- package/dist/primitives/badge.d.ts +1 -1
- package/dist/primitives/badge.d.ts.map +1 -1
- package/dist/primitives/badge.js +9 -4
- package/dist/primitives/badge.js.map +1 -1
- package/dist/primitives/button.d.ts +1 -1
- package/dist/primitives/button.js +1 -1
- package/dist/primitives/button.js.map +1 -1
- package/dist/primitives/dialog.d.ts +1 -1
- package/dist/primitives/dialog.d.ts.map +1 -1
- package/dist/primitives/dialog.js +10 -3
- package/dist/primitives/dialog.js.map +1 -1
- package/dist/primitives/select.js +1 -1
- package/dist/primitives/select.js.map +1 -1
- package/dist/primitives/sheet.d.ts +1 -1
- package/dist/primitives/sheet.d.ts.map +1 -1
- package/dist/primitives/sheet.js +6 -2
- package/dist/primitives/sheet.js.map +1 -1
- package/dist/providers/query-provider.d.ts +32 -1
- package/dist/providers/query-provider.d.ts.map +1 -1
- package/dist/providers/query-provider.js +53 -4
- package/dist/providers/query-provider.js.map +1 -1
- package/dist/render-cases-primitives.js +1 -1
- package/dist/render-cases-primitives.js.map +1 -1
- package/dist/render-cases.d.ts.map +1 -1
- package/dist/render-cases.js +30 -6
- package/dist/render-cases.js.map +1 -1
- package/dist/render-effects.d.ts +42 -0
- package/dist/render-effects.d.ts.map +1 -0
- package/dist/render-effects.js +81 -0
- package/dist/render-effects.js.map +1 -0
- package/dist/theme/create-theme.d.ts +19 -1
- package/dist/theme/create-theme.d.ts.map +1 -1
- package/dist/theme/create-theme.js +15 -0
- package/dist/theme/create-theme.js.map +1 -1
- package/package.json +5 -4
- package/src/app/adapt-data-widget.tsx +2 -42
- package/src/app/builder/BuilderBanners.tsx +1 -1
- package/src/app/builder/CanvasRow.tsx +63 -17
- package/src/app/builder/CatalogueSheet.tsx +50 -29
- package/src/app/builder/CellPropsSheet.tsx +17 -2
- package/src/app/builder/DiscardDialog.tsx +59 -0
- package/src/app/builder/LayoutSurface.tsx +206 -66
- package/src/app/builder/PipelineStrip.tsx +253 -153
- package/src/app/builder/SaveDialog.tsx +14 -2
- package/src/app/builder/Toolbar.tsx +144 -59
- package/src/app/builder/Workspace.tsx +226 -58
- package/src/app/builder/builder-model.ts +341 -2
- package/src/app/builder/builder-model.ux.test.ts +456 -0
- package/src/app/builder/builder-ux.test.tsx +844 -0
- package/src/app/builder/reveal.ts +10 -0
- package/src/app/builder/use-builder-catalogue.ts +56 -19
- package/src/app/builder/use-builder-session.ts +68 -0
- package/src/app/builder/use-display-resolver.ts +24 -0
- package/src/app/index.ts +12 -2
- package/src/app/layout-builder.tsx +178 -191
- package/src/app/mcp-app-view-chrome.test.tsx +141 -1
- package/src/app/mcp-app-view-chrome.tsx +56 -6
- package/src/app/mcp-app-view-states.tsx +51 -11
- package/src/app/mcp-app-view.effects.test.tsx +292 -0
- package/src/app/mcp-app-view.test.tsx +222 -2
- package/src/app/mcp-app-view.tsx +267 -117
- package/src/app/mcp-toolkit-app.test.tsx +40 -0
- package/src/app/mcp-toolkit-app.tsx +8 -1
- package/src/app/mcp-use-host-bridge.test.tsx +180 -0
- package/src/app/mcp-use-host-bridge.tsx +67 -24
- package/src/app/model-context.test.tsx +116 -0
- package/src/app/model-context.tsx +125 -0
- package/src/app/use-build-mode-focus.ts +62 -0
- package/src/app/use-view-actions.ts +89 -0
- package/src/app/use-view-lifecycle.ts +6 -0
- package/src/app/view-lifecycle.test.ts +216 -0
- package/src/app/view-lifecycle.ts +165 -5
- package/src/app/widget-fixture.effects.test.tsx +51 -0
- package/src/app/widget-fixture.render.test.tsx +81 -0
- package/src/app/widget-fixture.tsx +34 -11
- package/src/app/widget-props-sheet.tsx +57 -16
- package/src/app/widget-renderer.tsx +1 -1
- package/src/components/DrillButton.tsx +23 -10
- package/src/components/EmptyState.tsx +6 -4
- package/src/components/ErrorState.tsx +31 -9
- package/src/components/FilterBar.test.tsx +82 -0
- package/src/components/FilterBar.tsx +52 -26
- package/src/components/KpiGrid.test.tsx +279 -0
- package/src/components/KpiGrid.tsx +297 -90
- package/src/components/ListFooter.test.tsx +116 -0
- package/src/components/ListFooter.tsx +102 -15
- package/src/components/PagedListFooter.test.tsx +140 -0
- package/src/components/PagedListFooter.tsx +107 -0
- package/src/components/Pills.test.tsx +77 -0
- package/src/components/Pills.tsx +63 -10
- package/src/components/SectionHeading.tsx +9 -3
- package/src/components/Toast.tsx +37 -19
- package/src/components/WidgetHeader.test.tsx +78 -0
- package/src/components/WidgetHeader.tsx +63 -16
- package/src/components/feedback-states.test.tsx +98 -9
- package/src/components/tone-figures.test.tsx +8 -4
- package/src/hooks/index.ts +6 -3
- package/src/hooks/tool-result-recovery-model.test.ts +744 -0
- package/src/hooks/tool-result-recovery-model.ts +418 -0
- package/src/hooks/use-tool-query.effects.test.tsx +205 -0
- package/src/hooks/use-tool-query.ts +156 -33
- package/src/hooks/use-tool-result-recovery.effects.test.tsx +141 -0
- package/src/hooks/use-tool-result-recovery.test.ts +3 -4
- package/src/hooks/use-tool-result-recovery.ts +84 -142
- package/src/hooks/use-view-data.test.tsx +118 -9
- package/src/hooks/use-view-data.ts +128 -18
- package/src/hooks/use-view-tool-query.ts +25 -19
- package/src/i18n-coverage.test.tsx +104 -2
- package/src/index.ts +48 -11
- package/src/lib/focus-return.test.ts +288 -0
- package/src/lib/focus-return.ts +96 -0
- package/src/lib/parse-tool-result.test.ts +46 -1
- package/src/lib/parse-tool-result.ts +40 -2
- package/src/lib/toast-model.test.ts +166 -1
- package/src/lib/toast-model.ts +104 -2
- package/src/lib/tone-utils.test.ts +86 -10
- package/src/lib/tone-utils.ts +89 -8
- package/src/lib/tool-query.test.ts +569 -0
- package/src/lib/tool-query.ts +253 -0
- package/src/lib/tool-result-observer.test.ts +296 -0
- package/src/lib/tool-result-observer.ts +211 -0
- package/src/lib/typography.test.ts +45 -0
- package/src/lib/typography.ts +47 -0
- package/src/lib/utils.ts +12 -1
- package/src/locale/index.ts +1 -0
- package/src/locale/message-types.ts +118 -2
- package/src/locale/messages-de.ts +49 -10
- package/src/locale/messages-en.ts +49 -10
- package/src/locale/toolkit-messages.test.ts +133 -0
- package/src/locale/toolkit-messages.ts +63 -2
- package/src/primitives/alert.tsx +3 -0
- package/src/primitives/badge.tsx +9 -4
- package/src/primitives/button.tsx +1 -1
- package/src/primitives/dialog.tsx +10 -1
- package/src/primitives/select.tsx +1 -1
- package/src/primitives/sheet.tsx +7 -0
- package/src/providers/query-provider.test.tsx +154 -0
- package/src/providers/query-provider.tsx +66 -5
- package/src/render-cases-primitives.tsx +1 -1
- package/src/render-cases.tsx +38 -6
- package/src/render-effects.tsx +96 -0
- package/src/theme/cd-tokens.test.ts +3 -1
- package/src/theme/create-theme.test.ts +10 -0
- package/src/theme/create-theme.ts +28 -0
- package/src/theme/theme-css.test.ts +51 -0
- package/src/theme.css +31 -1
- 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
|
|
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
|
-
<
|
|
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
|
|
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
|
|
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).
|
|
6
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
92
|
-
|
|
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={
|
|
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
|
+
})
|