@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,288 @@
|
|
|
1
|
+
import { createElement } from "react"
|
|
2
|
+
import { renderToStaticMarkup } from "react-dom/server"
|
|
3
|
+
import ts from "typescript"
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest"
|
|
5
|
+
import { createFocusReturn, useFocusReturn, type AutoFocusEventLike } from "./focus-return.js"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Focus return of the kit's dialogs and sheets (CI §12 and §8 "Fokus im
|
|
9
|
+
* Dialog halten und danach zurückgeben"): the hand-back itself, and the
|
|
10
|
+
* gates that keep every overlay on it.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
declare global {
|
|
14
|
+
interface ImportMeta {
|
|
15
|
+
glob(
|
|
16
|
+
pattern: string | string[],
|
|
17
|
+
options: { query: "?raw"; import: "default"; eager: true },
|
|
18
|
+
): Record<string, string>
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const element = (isConnected = true) => ({ isConnected, focus: vi.fn<() => void>() })
|
|
23
|
+
const BODY = { body: true }
|
|
24
|
+
|
|
25
|
+
function closeEvent(defaultPrevented = false): AutoFocusEventLike & {
|
|
26
|
+
preventDefault: ReturnType<typeof vi.fn<() => void>>
|
|
27
|
+
} {
|
|
28
|
+
return { defaultPrevented, preventDefault: vi.fn<() => void>() }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("createFocusReturn", () => {
|
|
32
|
+
it("hands the focus back to the element that had it as the overlay opened", () => {
|
|
33
|
+
const fr = createFocusReturn()
|
|
34
|
+
const opener = element()
|
|
35
|
+
fr.capture(opener, BODY)
|
|
36
|
+
const event = closeEvent()
|
|
37
|
+
fr.restore(event, true)
|
|
38
|
+
expect(opener.focus).toHaveBeenCalledOnce()
|
|
39
|
+
// Radix's own close handler (it focuses only a Trigger) must not run after it.
|
|
40
|
+
expect(event.preventDefault).toHaveBeenCalledOnce()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it("leaves Radix's default alone when the body had the focus (nothing to return to)", () => {
|
|
44
|
+
const fr = createFocusReturn()
|
|
45
|
+
fr.capture(null, BODY)
|
|
46
|
+
const nothing = closeEvent()
|
|
47
|
+
fr.restore(nothing, true)
|
|
48
|
+
expect(nothing.preventDefault).not.toHaveBeenCalled()
|
|
49
|
+
|
|
50
|
+
const body = { ...element(), ...BODY }
|
|
51
|
+
fr.capture(body, body)
|
|
52
|
+
const event = closeEvent()
|
|
53
|
+
fr.restore(event, true)
|
|
54
|
+
expect(body.focus).not.toHaveBeenCalled()
|
|
55
|
+
expect(event.preventDefault).not.toHaveBeenCalled()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it("does not focus an opener that left the document (Apply or Discard unmounted it)", () => {
|
|
59
|
+
const fr = createFocusReturn()
|
|
60
|
+
const opener = element(false)
|
|
61
|
+
fr.capture(opener, BODY)
|
|
62
|
+
const event = closeEvent()
|
|
63
|
+
fr.restore(event, true)
|
|
64
|
+
expect(opener.focus).not.toHaveBeenCalled()
|
|
65
|
+
expect(event.preventDefault).not.toHaveBeenCalled()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it("leaves the focus where it went when it did not get lost with the overlay", () => {
|
|
69
|
+
// A click outside a non-modal overlay, or a hand-off (Apply) that already moved it.
|
|
70
|
+
const fr = createFocusReturn()
|
|
71
|
+
const opener = element()
|
|
72
|
+
fr.capture(opener, BODY)
|
|
73
|
+
const event = closeEvent()
|
|
74
|
+
fr.restore(event, false)
|
|
75
|
+
expect(opener.focus).not.toHaveBeenCalled()
|
|
76
|
+
expect(event.preventDefault).not.toHaveBeenCalled()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it("yields to a consumer's close handler that prevented the default", () => {
|
|
80
|
+
const fr = createFocusReturn()
|
|
81
|
+
const opener = element()
|
|
82
|
+
fr.capture(opener, BODY)
|
|
83
|
+
fr.restore(closeEvent(true), true)
|
|
84
|
+
expect(opener.focus).not.toHaveBeenCalled()
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it("returns the focus once per opening, to the latest opener", () => {
|
|
88
|
+
const fr = createFocusReturn()
|
|
89
|
+
const first = element()
|
|
90
|
+
const second = element()
|
|
91
|
+
fr.capture(first, BODY)
|
|
92
|
+
fr.capture(second, BODY)
|
|
93
|
+
fr.restore(closeEvent(), true)
|
|
94
|
+
fr.restore(closeEvent(), true)
|
|
95
|
+
expect(first.focus).not.toHaveBeenCalled()
|
|
96
|
+
expect(second.focus).toHaveBeenCalledOnce()
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
// -------------------------------------------------------------------------- //
|
|
101
|
+
// useFocusReturn: the order around the consumer's own handlers
|
|
102
|
+
// -------------------------------------------------------------------------- //
|
|
103
|
+
|
|
104
|
+
/** A Radix-like auto-focus event: `preventDefault()` sets `defaultPrevented`. */
|
|
105
|
+
function autoFocusEvent(): AutoFocusEventLike {
|
|
106
|
+
const event = {
|
|
107
|
+
defaultPrevented: false,
|
|
108
|
+
preventDefault: () => {
|
|
109
|
+
event.defaultPrevented = true
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
return event
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
type Handlers = ReturnType<typeof useFocusReturn<AutoFocusEventLike>>
|
|
116
|
+
|
|
117
|
+
/** The handler pair the hook hands a Radix content, from one SSR render. */
|
|
118
|
+
function handlersOf(consumer: Partial<Handlers>): Handlers {
|
|
119
|
+
let handlers: Handlers | undefined
|
|
120
|
+
function Probe() {
|
|
121
|
+
handlers = useFocusReturn(consumer)
|
|
122
|
+
return null
|
|
123
|
+
}
|
|
124
|
+
renderToStaticMarkup(createElement(Probe))
|
|
125
|
+
if (!handlers) throw new Error("the probe did not render")
|
|
126
|
+
return handlers
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
describe("useFocusReturn", () => {
|
|
130
|
+
const body = { tag: "body" }
|
|
131
|
+
const doc = { activeElement: null as unknown, body }
|
|
132
|
+
afterEach(() => {
|
|
133
|
+
vi.unstubAllGlobals()
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it("notes the opener BEFORE the consumer's open handler can move the focus", () => {
|
|
137
|
+
vi.stubGlobal("document", doc)
|
|
138
|
+
const opener = element()
|
|
139
|
+
const elsewhere = element()
|
|
140
|
+
const handlers = handlersOf({
|
|
141
|
+
onOpenAutoFocus: () => {
|
|
142
|
+
doc.activeElement = elsewhere
|
|
143
|
+
},
|
|
144
|
+
})
|
|
145
|
+
doc.activeElement = opener
|
|
146
|
+
handlers.onOpenAutoFocus(autoFocusEvent())
|
|
147
|
+
doc.activeElement = body // the overlay closed with the focus inside it
|
|
148
|
+
const close = autoFocusEvent()
|
|
149
|
+
handlers.onCloseAutoFocus(close)
|
|
150
|
+
expect(opener.focus).toHaveBeenCalledOnce()
|
|
151
|
+
expect(elsewhere.focus).not.toHaveBeenCalled()
|
|
152
|
+
expect(close.defaultPrevented).toBe(true)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it("runs the consumer's close handler first: its preventDefault keeps the focus with it", () => {
|
|
156
|
+
vi.stubGlobal("document", doc)
|
|
157
|
+
const opener = element()
|
|
158
|
+
const onCloseAutoFocus = vi.fn((event: AutoFocusEventLike) => event.preventDefault())
|
|
159
|
+
const handlers = handlersOf({ onCloseAutoFocus })
|
|
160
|
+
doc.activeElement = opener
|
|
161
|
+
handlers.onOpenAutoFocus(autoFocusEvent())
|
|
162
|
+
doc.activeElement = body
|
|
163
|
+
handlers.onCloseAutoFocus(autoFocusEvent())
|
|
164
|
+
expect(onCloseAutoFocus).toHaveBeenCalledOnce()
|
|
165
|
+
expect(opener.focus).not.toHaveBeenCalled()
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it("hands back on a lost focus only: the body, or none at all", () => {
|
|
169
|
+
vi.stubGlobal("document", doc)
|
|
170
|
+
const opener = element()
|
|
171
|
+
const handlers = handlersOf({})
|
|
172
|
+
for (const atClose of [body, null]) {
|
|
173
|
+
doc.activeElement = opener
|
|
174
|
+
handlers.onOpenAutoFocus(autoFocusEvent())
|
|
175
|
+
doc.activeElement = atClose
|
|
176
|
+
handlers.onCloseAutoFocus(autoFocusEvent())
|
|
177
|
+
}
|
|
178
|
+
expect(opener.focus).toHaveBeenCalledTimes(2)
|
|
179
|
+
|
|
180
|
+
// The focus already sits on another element: it stays there.
|
|
181
|
+
const moved = element()
|
|
182
|
+
doc.activeElement = opener
|
|
183
|
+
handlers.onOpenAutoFocus(autoFocusEvent())
|
|
184
|
+
doc.activeElement = moved
|
|
185
|
+
const close = autoFocusEvent()
|
|
186
|
+
handlers.onCloseAutoFocus(close)
|
|
187
|
+
expect(opener.focus).toHaveBeenCalledTimes(2)
|
|
188
|
+
expect(close.defaultPrevented).toBe(false)
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it("notes no opener that cannot take the focus", () => {
|
|
192
|
+
vi.stubGlobal("document", doc)
|
|
193
|
+
const handlers = handlersOf({})
|
|
194
|
+
doc.activeElement = { isConnected: true } // no focus()
|
|
195
|
+
handlers.onOpenAutoFocus(autoFocusEvent())
|
|
196
|
+
doc.activeElement = body
|
|
197
|
+
const close = autoFocusEvent()
|
|
198
|
+
expect(() => handlers.onCloseAutoFocus(close)).not.toThrow()
|
|
199
|
+
expect(close.defaultPrevented).toBe(false)
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
// -------------------------------------------------------------------------- //
|
|
204
|
+
// Gates: every overlay of the kit goes through the hand-back
|
|
205
|
+
// -------------------------------------------------------------------------- //
|
|
206
|
+
|
|
207
|
+
const SOURCES = import.meta.glob(["../**/*.tsx", "!../**/*.test.tsx"], {
|
|
208
|
+
query: "?raw",
|
|
209
|
+
import: "default",
|
|
210
|
+
eager: true,
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
/** Names of the JSX attributes on every `<tag …>` in a source (spreads as `...expr`). */
|
|
214
|
+
function attributesOf(file: string, source: string, tag: string): string[][] {
|
|
215
|
+
const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
|
|
216
|
+
const found: string[][] = []
|
|
217
|
+
const visit = (node: ts.Node) => {
|
|
218
|
+
if (
|
|
219
|
+
(ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) &&
|
|
220
|
+
node.tagName.getText(sf) === tag
|
|
221
|
+
) {
|
|
222
|
+
found.push(
|
|
223
|
+
node.attributes.properties.map((p) =>
|
|
224
|
+
ts.isJsxAttribute(p) ? p.name.getText(sf) : `...${p.expression.getText(sf)}`,
|
|
225
|
+
),
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
ts.forEachChild(node, visit)
|
|
229
|
+
}
|
|
230
|
+
visit(sf)
|
|
231
|
+
return found
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Whether a source puts `autoFocus` on any JSX element. */
|
|
235
|
+
function hasAutoFocus(file: string, source: string): boolean {
|
|
236
|
+
const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
|
|
237
|
+
let found = false
|
|
238
|
+
const visit = (node: ts.Node) => {
|
|
239
|
+
if (ts.isJsxAttribute(node) && node.name.getText(sf) === "autoFocus") found = true
|
|
240
|
+
ts.forEachChild(node, visit)
|
|
241
|
+
}
|
|
242
|
+
visit(sf)
|
|
243
|
+
return found
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
describe("overlay focus gates", () => {
|
|
247
|
+
it("scans the kit's components", () => {
|
|
248
|
+
expect(Object.keys(SOURCES)).toContain("../primitives/dialog.tsx")
|
|
249
|
+
expect(Object.keys(SOURCES)).toContain("../app/builder/DiscardDialog.tsx")
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it("imports Radix's Dialog only in the two primitives that add the hand-back", () => {
|
|
253
|
+
const importers = Object.entries(SOURCES)
|
|
254
|
+
.filter(([, src]) =>
|
|
255
|
+
/import\s*\{[^}]*\b(?:Alert)?Dialog\b[^}]*\}\s*from\s*"radix-ui"/.test(src),
|
|
256
|
+
)
|
|
257
|
+
.map(([file]) => file)
|
|
258
|
+
.sort()
|
|
259
|
+
expect(importers).toEqual(["../primitives/dialog.tsx", "../primitives/sheet.tsx"])
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
it("spreads the hand-back into the Radix content AFTER the consumer's props", () => {
|
|
263
|
+
for (const [file, primitive] of [
|
|
264
|
+
["../primitives/dialog.tsx", "DialogPrimitive.Content"],
|
|
265
|
+
["../primitives/sheet.tsx", "SheetPrimitive.Content"],
|
|
266
|
+
] as const) {
|
|
267
|
+
const contents = attributesOf(file, SOURCES[file] ?? "", primitive)
|
|
268
|
+
expect(contents, file).toHaveLength(1)
|
|
269
|
+
const attrs = contents[0] ?? []
|
|
270
|
+
expect(attrs.indexOf("...focusReturn"), file).toBeGreaterThan(attrs.indexOf("...props"))
|
|
271
|
+
expect(SOURCES[file], file).toMatch(
|
|
272
|
+
/useFocusReturn\(\{ onOpenAutoFocus, onCloseAutoFocus \}\)/,
|
|
273
|
+
)
|
|
274
|
+
}
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it("lets no overlay content take the focus itself (Radix would skip noting the opener)", () => {
|
|
278
|
+
const overlays = Object.entries(SOURCES).filter(([, src]) =>
|
|
279
|
+
/<(DialogContent|SheetContent)\b/.test(src),
|
|
280
|
+
)
|
|
281
|
+
expect(overlays.length).toBeGreaterThan(3)
|
|
282
|
+
expect(overlays.filter(([file, src]) => hasAutoFocus(file, src)).map(([file]) => file)).toEqual(
|
|
283
|
+
[],
|
|
284
|
+
)
|
|
285
|
+
expect(hasAutoFocus("self.tsx", "const A = () => <input autoFocus />")).toBe(true)
|
|
286
|
+
expect(hasAutoFocus("self.tsx", "const autoFocus = 1")).toBe(false)
|
|
287
|
+
})
|
|
288
|
+
})
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { useState } from "react"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Focus return for the kit's dialogs and sheets (CI §12: focus goes back to
|
|
5
|
+
* where it came from).
|
|
6
|
+
*
|
|
7
|
+
* A Radix dialog returns the focus only to its `Trigger`. The kit's overlays
|
|
8
|
+
* mostly open from React state, without one (the builder's Save, Discard and
|
|
9
|
+
* Catalogue, a cell's props sheet), and Radix then cancels its focus scope's
|
|
10
|
+
* own restore and focuses nothing: closing them by keyboard left the focus on
|
|
11
|
+
* the document body. `DialogContent` and `SheetContent` therefore note the
|
|
12
|
+
* element that has the focus as they open and hand the focus back to it when
|
|
13
|
+
* they close and the focus went with them, while that element is still in the
|
|
14
|
+
* document. A consumer's own `onCloseAutoFocus` that calls `preventDefault()`
|
|
15
|
+
* keeps full control.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** The part of an element the hand-back needs (structural, so it tests without a DOM). */
|
|
19
|
+
export interface FocusReturnTarget {
|
|
20
|
+
readonly isConnected: boolean
|
|
21
|
+
focus: () => void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The part of Radix's auto-focus events the hand-back reads. */
|
|
25
|
+
export interface AutoFocusEventLike {
|
|
26
|
+
readonly defaultPrevented: boolean
|
|
27
|
+
preventDefault: () => void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** One overlay's hand-back, from {@link createFocusReturn}. */
|
|
31
|
+
export interface FocusReturn {
|
|
32
|
+
/**
|
|
33
|
+
* Notes the element that has the focus as the overlay opens. The document
|
|
34
|
+
* body is no target: a pointer that opened it without focusing anything
|
|
35
|
+
* (Safari does not focus a clicked button) leaves Radix's default in place.
|
|
36
|
+
*/
|
|
37
|
+
capture: (active: FocusReturnTarget | null, body: unknown) => void
|
|
38
|
+
/**
|
|
39
|
+
* Hands the focus back to the noted element when the overlay closes and the
|
|
40
|
+
* focus went with it (`focusLost`: it sits on the body). It leaves the focus
|
|
41
|
+
* where it is when a handler before it prevented that, when the focus
|
|
42
|
+
* already sits elsewhere (a click outside a non-modal overlay, a hand-off
|
|
43
|
+
* that moved it) or when the element left the document (an Apply or
|
|
44
|
+
* Discard that unmounted the builder). Forgets the element either way.
|
|
45
|
+
*/
|
|
46
|
+
restore: (event: AutoFocusEventLike, focusLost: boolean) => void
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A {@link FocusReturn} with nothing noted yet. */
|
|
50
|
+
export function createFocusReturn(): FocusReturn {
|
|
51
|
+
let opener: FocusReturnTarget | null = null
|
|
52
|
+
return {
|
|
53
|
+
capture: (active, body) => {
|
|
54
|
+
opener = active === body ? null : active
|
|
55
|
+
},
|
|
56
|
+
restore: (event, focusLost) => {
|
|
57
|
+
const target = opener
|
|
58
|
+
opener = null
|
|
59
|
+
if (event.defaultPrevented || !focusLost || target === null || !target.isConnected) return
|
|
60
|
+
event.preventDefault()
|
|
61
|
+
target.focus()
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The `onOpenAutoFocus` / `onCloseAutoFocus` pair of a Radix dialog content
|
|
68
|
+
* with the focus hand-back of {@link createFocusReturn} composed in: it notes
|
|
69
|
+
* the opener BEFORE the consumer's open handler runs (that one may move the
|
|
70
|
+
* focus) and hands it back AFTER the consumer's close handler (whose
|
|
71
|
+
* `preventDefault()` keeps it out). Radix fires the open event only when the
|
|
72
|
+
* focus is outside the content as it mounts, so an element inside it must not
|
|
73
|
+
* take the focus itself (`autoFocus`): Radix focuses the first one anyway.
|
|
74
|
+
*/
|
|
75
|
+
export function useFocusReturn<E extends AutoFocusEventLike>(handlers: {
|
|
76
|
+
onOpenAutoFocus?: (event: E) => void
|
|
77
|
+
onCloseAutoFocus?: (event: E) => void
|
|
78
|
+
}): { onOpenAutoFocus: (event: E) => void; onCloseAutoFocus: (event: E) => void } {
|
|
79
|
+
const [focusReturn] = useState(createFocusReturn)
|
|
80
|
+
return {
|
|
81
|
+
onOpenAutoFocus: (event) => {
|
|
82
|
+
focusReturn.capture(activeElementOf(document), document.body)
|
|
83
|
+
handlers.onOpenAutoFocus?.(event)
|
|
84
|
+
},
|
|
85
|
+
onCloseAutoFocus: (event) => {
|
|
86
|
+
handlers.onCloseAutoFocus?.(event)
|
|
87
|
+
const active = document.activeElement
|
|
88
|
+
focusReturn.restore(event, active === null || active === document.body)
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function activeElementOf(doc: Document): FocusReturnTarget | null {
|
|
94
|
+
const active = doc.activeElement
|
|
95
|
+
return active && "focus" in active ? (active as HTMLElement) : null
|
|
96
|
+
}
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest"
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
isToolError,
|
|
4
|
+
parseToolResult,
|
|
5
|
+
parseViewToolResult,
|
|
6
|
+
ToolResultError,
|
|
7
|
+
} from "./parse-tool-result.js"
|
|
3
8
|
|
|
4
9
|
describe("parseToolResult (structuredContent-first contract)", () => {
|
|
5
10
|
it("prefers structuredContent over the text channel", () => {
|
|
@@ -137,3 +142,43 @@ describe("parseViewToolResult (show-tool envelope unwrap)", () => {
|
|
|
137
142
|
expect(() => parseViewToolResult(result)).toThrowError("boom")
|
|
138
143
|
})
|
|
139
144
|
})
|
|
145
|
+
|
|
146
|
+
describe("ToolResultError / isToolError (the query layer's no-retry signal)", () => {
|
|
147
|
+
it("an isError result throws a ToolResultError that carries the result", () => {
|
|
148
|
+
const result = { isError: true, content: [{ type: "text", text: "[404] gone" }] }
|
|
149
|
+
let thrown: unknown
|
|
150
|
+
try {
|
|
151
|
+
parseToolResult(result)
|
|
152
|
+
} catch (err) {
|
|
153
|
+
thrown = err
|
|
154
|
+
}
|
|
155
|
+
expect(thrown).toBeInstanceOf(ToolResultError)
|
|
156
|
+
expect(thrown).toBeInstanceOf(Error)
|
|
157
|
+
expect(thrown).toMatchObject({ name: "ToolResultError", message: "[404] gone", result })
|
|
158
|
+
expect(isToolError(thrown)).toBe(true)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it("recognises mcp-use's ToolError by its name and its isError result", () => {
|
|
162
|
+
const toolError = Object.assign(new Error("busy"), {
|
|
163
|
+
name: "ToolError",
|
|
164
|
+
result: { isError: true, content: [] },
|
|
165
|
+
})
|
|
166
|
+
expect(isToolError(toolError)).toBe(true)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it("anything else is no tool error: a transport failure, a look-alike, a non-error", () => {
|
|
170
|
+
expect(isToolError(new Error("connection lost"))).toBe(false)
|
|
171
|
+
expect(isToolError(Object.assign(new Error("x"), { name: "ToolError" }))).toBe(false)
|
|
172
|
+
expect(
|
|
173
|
+
isToolError(Object.assign(new Error("x"), { name: "ToolError", result: { isError: false } })),
|
|
174
|
+
).toBe(false)
|
|
175
|
+
expect(isToolError(Object.assign(new Error("x"), { name: "ToolError", result: null }))).toBe(
|
|
176
|
+
false,
|
|
177
|
+
)
|
|
178
|
+
expect(
|
|
179
|
+
isToolError(Object.assign(new Error("x"), { name: "Other", result: { isError: true } })),
|
|
180
|
+
).toBe(false)
|
|
181
|
+
expect(isToolError({ name: "ToolError", result: { isError: true } })).toBe(false)
|
|
182
|
+
expect(isToolError(undefined)).toBe(false)
|
|
183
|
+
})
|
|
184
|
+
})
|
|
@@ -16,10 +16,48 @@ export interface ParseToolResultOptions {
|
|
|
16
16
|
prefer?: "structured" | "text"
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* A tool answered with an `isError` result: a deterministic failure of the
|
|
21
|
+
* call itself (a validation error, an engine 404), not of the transport.
|
|
22
|
+
* Thrown by {@link parseToolResult}. The query hooks retry it like any
|
|
23
|
+
* failure by default (the client's `retry`); pass `retry: shouldRetryToolCall`
|
|
24
|
+
* to stop that, since repeating the call repeats the answer. Still an `Error`
|
|
25
|
+
* with the tool's first text block as its message.
|
|
26
|
+
*/
|
|
27
|
+
export class ToolResultError extends Error {
|
|
28
|
+
/** The `isError` result as the tool returned it. */
|
|
29
|
+
readonly result: unknown
|
|
30
|
+
|
|
31
|
+
constructor(message: string, result: unknown) {
|
|
32
|
+
super(message)
|
|
33
|
+
this.name = "ToolResultError"
|
|
34
|
+
this.result = result
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
39
|
+
return typeof value === "object" && value !== null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Whether `error` is a tool's own `isError` answer: a {@link ToolResultError},
|
|
44
|
+
* or mcp-use's `ToolError` (how `useDynamicTool` rejects an `isError` result
|
|
45
|
+
* inside a view), recognised by its name and result so the root barrel never
|
|
46
|
+
* imports `mcp-use/react`. Transport failures (a lost connection, a JSON-RPC
|
|
47
|
+
* error such as an unknown tool) are not tool errors.
|
|
48
|
+
*/
|
|
49
|
+
export function isToolError(error: unknown): boolean {
|
|
50
|
+
if (error instanceof ToolResultError) return true
|
|
51
|
+
if (!(error instanceof Error) || error.name !== "ToolError") return false
|
|
52
|
+
const result: unknown = (error as Error & { result?: unknown }).result
|
|
53
|
+
return isRecord(result) && result.isError === true
|
|
54
|
+
}
|
|
55
|
+
|
|
19
56
|
/**
|
|
20
57
|
* Decodes an MCP tool result down to its data payload.
|
|
21
58
|
*
|
|
22
|
-
* Default (`prefer: "structured"`): `isError` → throw
|
|
59
|
+
* Default (`prefer: "structured"`): `isError` → throw a
|
|
60
|
+
* {@link ToolResultError} with the first text block,
|
|
23
61
|
* then `structuredContent` (if non-null), then JSON-decode the first text
|
|
24
62
|
* block (verbatim string if it isn't JSON), then the raw result.
|
|
25
63
|
*
|
|
@@ -37,7 +75,7 @@ export function parseToolResult<T = unknown>(result: unknown, opts?: ParseToolRe
|
|
|
37
75
|
isError?: boolean
|
|
38
76
|
}
|
|
39
77
|
| undefined
|
|
40
|
-
if (r?.isError) throw new
|
|
78
|
+
if (r?.isError) throw new ToolResultError(r.content?.[0]?.text ?? "Tool call failed", result)
|
|
41
79
|
|
|
42
80
|
const fromStructured = (): T | undefined =>
|
|
43
81
|
r?.structuredContent != null ? (r.structuredContent as T) : undefined
|
|
@@ -1,12 +1,16 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest"
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
|
2
2
|
import {
|
|
3
3
|
createToast,
|
|
4
|
+
createToastTimer,
|
|
4
5
|
MAX_VISIBLE_TOASTS,
|
|
6
|
+
resolveToastTone,
|
|
5
7
|
TOAST_DURATION_MS,
|
|
6
8
|
TOAST_ERROR_DURATION_MS,
|
|
7
9
|
toastDuration,
|
|
8
10
|
toastReducer,
|
|
11
|
+
type ToastClock,
|
|
9
12
|
type ToastItem,
|
|
13
|
+
type ToastOptions,
|
|
10
14
|
} from "./toast-model.js"
|
|
11
15
|
|
|
12
16
|
const item = (id: number): ToastItem => createToast(id, { title: `t${id}` })
|
|
@@ -50,6 +54,34 @@ describe("createToast", () => {
|
|
|
50
54
|
duration: 5000,
|
|
51
55
|
})
|
|
52
56
|
})
|
|
57
|
+
|
|
58
|
+
it("resolves an untyped retired tone instead of keeping a name no icon exists for", () => {
|
|
59
|
+
// Plain JS or wire data: `error` is `danger`, with the error toast's 5 s.
|
|
60
|
+
const fromWire = { tone: "error", title: "Failed" } as unknown as ToastOptions
|
|
61
|
+
expect(createToast(4, fromWire)).toMatchObject({ tone: "danger", duration: 5000 })
|
|
62
|
+
const unknown = { tone: "purple", title: "x" } as unknown as ToastOptions
|
|
63
|
+
expect(createToast(5, unknown).tone).toBe("info")
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
describe("resolveToastTone (fail-soft for an untyped tone)", () => {
|
|
68
|
+
it("keeps the four toast tones and maps a retired name through resolveTone", () => {
|
|
69
|
+
for (const tone of ["success", "warning", "danger", "info"] as const) {
|
|
70
|
+
expect(resolveToastTone(tone)).toBe(tone)
|
|
71
|
+
}
|
|
72
|
+
expect(resolveToastTone("critical")).toBe("danger")
|
|
73
|
+
expect(resolveToastTone("Warn")).toBe("warning")
|
|
74
|
+
expect(resolveToastTone("ok")).toBe("success")
|
|
75
|
+
expect(resolveToastTone("notice")).toBe("info")
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it("shows neutral, unknown and missing tones as info (a toast has no neutral edge)", () => {
|
|
79
|
+
expect(resolveToastTone("neutral")).toBe("info")
|
|
80
|
+
expect(resolveToastTone("secondary")).toBe("info")
|
|
81
|
+
expect(resolveToastTone("purple")).toBe("info")
|
|
82
|
+
expect(resolveToastTone(undefined)).toBe("info")
|
|
83
|
+
expect(resolveToastTone(42)).toBe("info")
|
|
84
|
+
})
|
|
53
85
|
})
|
|
54
86
|
|
|
55
87
|
describe("toastReducer", () => {
|
|
@@ -66,3 +98,136 @@ describe("toastReducer", () => {
|
|
|
66
98
|
expect(toastReducer(state, { type: "dismiss", id: 9 })).toBe(state)
|
|
67
99
|
})
|
|
68
100
|
})
|
|
101
|
+
|
|
102
|
+
describe("createToastTimer (a held toast resumes its REMAINING time)", () => {
|
|
103
|
+
beforeEach(() => {
|
|
104
|
+
vi.useFakeTimers()
|
|
105
|
+
})
|
|
106
|
+
afterEach(() => {
|
|
107
|
+
vi.useRealTimers()
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it("expires once, after the full duration, when nothing holds it", () => {
|
|
111
|
+
const onExpire = vi.fn()
|
|
112
|
+
const timer = createToastTimer(2800, onExpire)
|
|
113
|
+
expect(timer.remaining()).toBe(2800)
|
|
114
|
+
vi.advanceTimersByTime(2799)
|
|
115
|
+
expect(onExpire).not.toHaveBeenCalled()
|
|
116
|
+
expect(timer.remaining()).toBe(1)
|
|
117
|
+
vi.advanceTimersByTime(1)
|
|
118
|
+
expect(onExpire).toHaveBeenCalledTimes(1)
|
|
119
|
+
expect(timer.remaining()).toBe(0)
|
|
120
|
+
vi.advanceTimersByTime(10_000)
|
|
121
|
+
expect(onExpire).toHaveBeenCalledTimes(1)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it("pauses while held and resumes with the time that was left, not a fresh duration", () => {
|
|
125
|
+
const onExpire = vi.fn()
|
|
126
|
+
const timer = createToastTimer(2800, onExpire)
|
|
127
|
+
vi.advanceTimersByTime(1000)
|
|
128
|
+
timer.hold("pointer")
|
|
129
|
+
expect(timer.remaining()).toBe(1800)
|
|
130
|
+
vi.advanceTimersByTime(60_000) // reading for a minute: nothing runs out
|
|
131
|
+
expect(onExpire).not.toHaveBeenCalled()
|
|
132
|
+
expect(timer.remaining()).toBe(1800)
|
|
133
|
+
timer.release("pointer")
|
|
134
|
+
vi.advanceTimersByTime(1799)
|
|
135
|
+
expect(onExpire).not.toHaveBeenCalled()
|
|
136
|
+
vi.advanceTimersByTime(1)
|
|
137
|
+
expect(onExpire).toHaveBeenCalledTimes(1)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it("keeps counting down across several holds (each resume continues the rest)", () => {
|
|
141
|
+
const onExpire = vi.fn()
|
|
142
|
+
const timer = createToastTimer(5000, onExpire)
|
|
143
|
+
vi.advanceTimersByTime(2000)
|
|
144
|
+
timer.hold("pointer")
|
|
145
|
+
vi.advanceTimersByTime(500)
|
|
146
|
+
timer.release("pointer")
|
|
147
|
+
vi.advanceTimersByTime(2000)
|
|
148
|
+
timer.hold("focus")
|
|
149
|
+
expect(timer.remaining()).toBe(1000)
|
|
150
|
+
timer.release("focus")
|
|
151
|
+
vi.advanceTimersByTime(999)
|
|
152
|
+
expect(onExpire).not.toHaveBeenCalled()
|
|
153
|
+
vi.advanceTimersByTime(1)
|
|
154
|
+
expect(onExpire).toHaveBeenCalledTimes(1)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it("stays held until EVERY reason is released (hover and keyboard focus overlap)", () => {
|
|
158
|
+
const onExpire = vi.fn()
|
|
159
|
+
const timer = createToastTimer(1000, onExpire)
|
|
160
|
+
timer.hold("pointer")
|
|
161
|
+
timer.hold("focus")
|
|
162
|
+
timer.hold("focus")
|
|
163
|
+
timer.release("pointer")
|
|
164
|
+
vi.advanceTimersByTime(5000)
|
|
165
|
+
expect(onExpire).not.toHaveBeenCalled()
|
|
166
|
+
timer.release("unknown")
|
|
167
|
+
vi.advanceTimersByTime(5000)
|
|
168
|
+
expect(onExpire).not.toHaveBeenCalled()
|
|
169
|
+
timer.release("focus")
|
|
170
|
+
vi.advanceTimersByTime(1000)
|
|
171
|
+
expect(onExpire).toHaveBeenCalledTimes(1)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it("never fires after cancel, released or not", () => {
|
|
175
|
+
const onExpire = vi.fn()
|
|
176
|
+
const timer = createToastTimer(1000, onExpire)
|
|
177
|
+
vi.advanceTimersByTime(400)
|
|
178
|
+
timer.cancel()
|
|
179
|
+
expect(timer.remaining()).toBe(600)
|
|
180
|
+
timer.release("pointer")
|
|
181
|
+
vi.advanceTimersByTime(5000)
|
|
182
|
+
expect(onExpire).not.toHaveBeenCalled()
|
|
183
|
+
const held = createToastTimer(1000, onExpire)
|
|
184
|
+
held.hold("focus")
|
|
185
|
+
held.cancel()
|
|
186
|
+
held.release("focus")
|
|
187
|
+
vi.advanceTimersByTime(5000)
|
|
188
|
+
expect(onExpire).not.toHaveBeenCalled()
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it("expires at once for a non-positive duration", () => {
|
|
192
|
+
const onExpire = vi.fn()
|
|
193
|
+
const timer = createToastTimer(-50, onExpire)
|
|
194
|
+
expect(timer.remaining()).toBe(0)
|
|
195
|
+
vi.advanceTimersByTime(0)
|
|
196
|
+
expect(onExpire).toHaveBeenCalledTimes(1)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it("runs on an injected clock and clears the handle it scheduled", () => {
|
|
200
|
+
let now = 100
|
|
201
|
+
const pending = new Map<number, () => void>()
|
|
202
|
+
let nextHandle = 0
|
|
203
|
+
const cleared: unknown[] = []
|
|
204
|
+
const scheduled: number[] = []
|
|
205
|
+
const clock: ToastClock = {
|
|
206
|
+
now: () => now,
|
|
207
|
+
setTimeout: (callback, ms) => {
|
|
208
|
+
nextHandle += 1
|
|
209
|
+
pending.set(nextHandle, callback)
|
|
210
|
+
scheduled.push(ms)
|
|
211
|
+
return nextHandle
|
|
212
|
+
},
|
|
213
|
+
clearTimeout: (handle) => {
|
|
214
|
+
cleared.push(handle)
|
|
215
|
+
pending.delete(handle as number)
|
|
216
|
+
},
|
|
217
|
+
}
|
|
218
|
+
const onExpire = vi.fn()
|
|
219
|
+
const timer = createToastTimer(2800, onExpire, clock)
|
|
220
|
+
expect(scheduled).toEqual([2800])
|
|
221
|
+
now += 800
|
|
222
|
+
timer.hold("pointer")
|
|
223
|
+
expect(cleared).toEqual([1])
|
|
224
|
+
expect(pending.size).toBe(0)
|
|
225
|
+
now += 10_000
|
|
226
|
+
timer.release("pointer")
|
|
227
|
+
expect(scheduled).toEqual([2800, 2000])
|
|
228
|
+
now += 2000
|
|
229
|
+
pending.get(2)?.()
|
|
230
|
+
expect(onExpire).toHaveBeenCalledTimes(1)
|
|
231
|
+
expect(timer.remaining()).toBe(0)
|
|
232
|
+
})
|
|
233
|
+
})
|