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