@miragon/mcp-toolkit-ui 2.6.0 → 2.7.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 -1
- package/dist/app/builder/BuilderBanners.d.ts +7 -2
- package/dist/app/builder/BuilderBanners.d.ts.map +1 -1
- package/dist/app/builder/BuilderBanners.js +8 -3
- package/dist/app/builder/BuilderBanners.js.map +1 -1
- package/dist/app/builder/CanvasRow.d.ts.map +1 -1
- package/dist/app/builder/CanvasRow.js +12 -14
- 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 +6 -5
- package/dist/app/builder/CatalogueSheet.js.map +1 -1
- package/dist/app/builder/EmptyHint.d.ts +4 -0
- package/dist/app/builder/EmptyHint.d.ts.map +1 -1
- package/dist/app/builder/EmptyHint.js +6 -1
- package/dist/app/builder/EmptyHint.js.map +1 -1
- package/dist/app/builder/LayoutSurface.js +3 -3
- package/dist/app/builder/LayoutSurface.js.map +1 -1
- package/dist/app/builder/PipelineStrip.d.ts +3 -1
- package/dist/app/builder/PipelineStrip.d.ts.map +1 -1
- package/dist/app/builder/PipelineStrip.js +11 -6
- package/dist/app/builder/PipelineStrip.js.map +1 -1
- package/dist/app/builder/SaveDialog.d.ts.map +1 -1
- package/dist/app/builder/SaveDialog.js +2 -2
- package/dist/app/builder/SaveDialog.js.map +1 -1
- package/dist/app/builder/Toolbar.d.ts +3 -1
- package/dist/app/builder/Toolbar.d.ts.map +1 -1
- package/dist/app/builder/Toolbar.js +9 -3
- package/dist/app/builder/Toolbar.js.map +1 -1
- package/dist/app/builder/Workspace.js +2 -2
- package/dist/app/builder/Workspace.js.map +1 -1
- package/dist/app/builder/builder-model.d.ts +3 -2
- package/dist/app/builder/builder-model.d.ts.map +1 -1
- package/dist/app/builder/builder-model.js +5 -4
- package/dist/app/builder/builder-model.js.map +1 -1
- package/dist/app/builder/labels.d.ts +13 -62
- package/dist/app/builder/labels.d.ts.map +1 -1
- package/dist/app/builder/labels.js +9 -58
- package/dist/app/builder/labels.js.map +1 -1
- package/dist/app/builder/status-chip.d.ts.map +1 -1
- package/dist/app/builder/status-chip.js +3 -2
- package/dist/app/builder/status-chip.js.map +1 -1
- package/dist/app/builder/use-builder-catalogue.d.ts +1 -0
- package/dist/app/builder/use-builder-catalogue.d.ts.map +1 -1
- package/dist/app/builder/use-builder-catalogue.js +6 -0
- package/dist/app/builder/use-builder-catalogue.js.map +1 -1
- package/dist/app/builder/use-save-dashboard.d.ts +3 -1
- package/dist/app/builder/use-save-dashboard.d.ts.map +1 -1
- package/dist/app/builder/use-save-dashboard.js +3 -1
- package/dist/app/builder/use-save-dashboard.js.map +1 -1
- package/dist/app/index.d.ts +1 -1
- package/dist/app/index.d.ts.map +1 -1
- package/dist/app/index.js.map +1 -1
- package/dist/app/layout-builder.d.ts +7 -2
- package/dist/app/layout-builder.d.ts.map +1 -1
- package/dist/app/layout-builder.js +17 -5
- package/dist/app/layout-builder.js.map +1 -1
- package/dist/app/mcp-app-view-chrome.d.ts +94 -20
- package/dist/app/mcp-app-view-chrome.d.ts.map +1 -1
- package/dist/app/mcp-app-view-chrome.js +109 -40
- package/dist/app/mcp-app-view-chrome.js.map +1 -1
- package/dist/app/mcp-app-view-states.d.ts +15 -0
- package/dist/app/mcp-app-view-states.d.ts.map +1 -0
- package/dist/app/mcp-app-view-states.js +34 -0
- package/dist/app/mcp-app-view-states.js.map +1 -0
- package/dist/app/mcp-app-view.d.ts +40 -5
- package/dist/app/mcp-app-view.d.ts.map +1 -1
- package/dist/app/mcp-app-view.js +174 -128
- package/dist/app/mcp-app-view.js.map +1 -1
- package/dist/app/mcp-toolkit-app.d.ts +2 -1
- package/dist/app/mcp-toolkit-app.d.ts.map +1 -1
- package/dist/app/mcp-toolkit-app.js +25 -3
- package/dist/app/mcp-toolkit-app.js.map +1 -1
- package/dist/app/use-view-lifecycle.d.ts +38 -0
- package/dist/app/use-view-lifecycle.d.ts.map +1 -0
- package/dist/app/use-view-lifecycle.js +79 -0
- package/dist/app/use-view-lifecycle.js.map +1 -0
- package/dist/app/view-lifecycle.d.ts +109 -0
- package/dist/app/view-lifecycle.d.ts.map +1 -0
- package/dist/app/view-lifecycle.js +120 -0
- package/dist/app/view-lifecycle.js.map +1 -0
- package/dist/app/widget-props-sheet.d.ts +3 -9
- package/dist/app/widget-props-sheet.d.ts.map +1 -1
- package/dist/app/widget-props-sheet.js +6 -13
- package/dist/app/widget-props-sheet.js.map +1 -1
- package/dist/app/widget-renderer.d.ts +41 -1
- package/dist/app/widget-renderer.d.ts.map +1 -1
- package/dist/app/widget-renderer.js +82 -31
- package/dist/app/widget-renderer.js.map +1 -1
- package/dist/components/DrillButton.d.ts.map +1 -1
- package/dist/components/DrillButton.js +2 -1
- package/dist/components/DrillButton.js.map +1 -1
- package/dist/components/EmptyState.d.ts +33 -0
- package/dist/components/EmptyState.d.ts.map +1 -0
- package/dist/components/EmptyState.js +17 -0
- package/dist/components/EmptyState.js.map +1 -0
- package/dist/components/ErrorState.d.ts +50 -0
- package/dist/components/ErrorState.d.ts.map +1 -0
- package/dist/components/ErrorState.js +60 -0
- package/dist/components/ErrorState.js.map +1 -0
- package/dist/components/FilterBar.d.ts +1 -1
- package/dist/components/FilterBar.d.ts.map +1 -1
- package/dist/components/FilterBar.js +7 -4
- package/dist/components/FilterBar.js.map +1 -1
- package/dist/components/GroupCard.d.ts.map +1 -1
- package/dist/components/GroupCard.js +2 -1
- package/dist/components/GroupCard.js.map +1 -1
- package/dist/components/KpiGrid.d.ts +10 -3
- package/dist/components/KpiGrid.d.ts.map +1 -1
- package/dist/components/KpiGrid.js +31 -9
- package/dist/components/KpiGrid.js.map +1 -1
- package/dist/components/ListFooter.d.ts +3 -2
- package/dist/components/ListFooter.d.ts.map +1 -1
- package/dist/components/ListFooter.js +14 -4
- package/dist/components/ListFooter.js.map +1 -1
- package/dist/components/Pills.d.ts +10 -6
- package/dist/components/Pills.d.ts.map +1 -1
- package/dist/components/Pills.js +12 -6
- package/dist/components/Pills.js.map +1 -1
- package/dist/components/Toast.d.ts +37 -0
- package/dist/components/Toast.d.ts.map +1 -0
- package/dist/components/Toast.js +91 -0
- package/dist/components/Toast.js.map +1 -0
- package/dist/components/WidgetHeader.js +1 -1
- package/dist/hooks/use-tool-result-recovery.d.ts +23 -1
- package/dist/hooks/use-tool-result-recovery.d.ts.map +1 -1
- package/dist/hooks/use-tool-result-recovery.js +21 -7
- package/dist/hooks/use-tool-result-recovery.js.map +1 -1
- package/dist/hooks/use-view-data.d.ts +6 -0
- package/dist/hooks/use-view-data.d.ts.map +1 -1
- package/dist/hooks/use-view-data.js +6 -1
- package/dist/hooks/use-view-data.js.map +1 -1
- package/dist/index.d.ts +12 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -4
- package/dist/index.js.map +1 -1
- package/dist/lib/focus-ring.d.ts +18 -0
- package/dist/lib/focus-ring.d.ts.map +1 -0
- package/dist/lib/focus-ring.js +18 -0
- package/dist/lib/focus-ring.js.map +1 -0
- package/dist/lib/icon-stroke.d.ts +12 -0
- package/dist/lib/icon-stroke.d.ts.map +1 -0
- package/dist/lib/icon-stroke.js +12 -0
- package/dist/lib/icon-stroke.js.map +1 -0
- package/dist/lib/toast-model.d.ts +46 -0
- package/dist/lib/toast-model.d.ts.map +1 -0
- package/dist/lib/toast-model.js +36 -0
- package/dist/lib/toast-model.js.map +1 -0
- package/dist/lib/tone-utils.d.ts +43 -9
- package/dist/lib/tone-utils.d.ts.map +1 -1
- package/dist/lib/tone-utils.js +36 -7
- package/dist/lib/tone-utils.js.map +1 -1
- package/dist/locale/index.d.ts +3 -1
- package/dist/locale/index.d.ts.map +1 -1
- package/dist/locale/index.js +2 -1
- package/dist/locale/index.js.map +1 -1
- package/dist/locale/locale-provider.d.ts +42 -1
- package/dist/locale/locale-provider.d.ts.map +1 -1
- package/dist/locale/locale-provider.js +44 -3
- package/dist/locale/locale-provider.js.map +1 -1
- package/dist/locale/message-types.d.ts +223 -0
- package/dist/locale/message-types.d.ts.map +1 -0
- package/dist/locale/message-types.js +7 -0
- package/dist/locale/message-types.js.map +1 -0
- package/dist/locale/messages-de.d.ts +9 -0
- package/dist/locale/messages-de.d.ts.map +1 -0
- package/dist/locale/messages-de.js +151 -0
- package/dist/locale/messages-de.js.map +1 -0
- package/dist/locale/messages-en.d.ts +8 -0
- package/dist/locale/messages-en.d.ts.map +1 -0
- package/dist/locale/messages-en.js +150 -0
- package/dist/locale/messages-en.js.map +1 -0
- package/dist/locale/toolkit-messages.d.ts +39 -0
- package/dist/locale/toolkit-messages.d.ts.map +1 -0
- package/dist/locale/toolkit-messages.js +50 -0
- package/dist/locale/toolkit-messages.js.map +1 -0
- package/dist/primitives/alert.d.ts.map +1 -1
- package/dist/primitives/alert.js +4 -2
- 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 +14 -6
- package/dist/primitives/badge.js.map +1 -1
- package/dist/primitives/button.d.ts.map +1 -1
- package/dist/primitives/button.js +12 -8
- package/dist/primitives/button.js.map +1 -1
- package/dist/primitives/card.js +4 -4
- package/dist/primitives/card.js.map +1 -1
- package/dist/primitives/dialog.d.ts +7 -3
- package/dist/primitives/dialog.d.ts.map +1 -1
- package/dist/primitives/dialog.js +14 -7
- package/dist/primitives/dialog.js.map +1 -1
- package/dist/primitives/input.d.ts.map +1 -1
- package/dist/primitives/input.js +4 -1
- package/dist/primitives/input.js.map +1 -1
- package/dist/primitives/scroll-area.js +1 -1
- package/dist/primitives/scroll-area.js.map +1 -1
- package/dist/primitives/select.d.ts.map +1 -1
- package/dist/primitives/select.js +5 -2
- package/dist/primitives/select.js.map +1 -1
- package/dist/primitives/sheet.d.ts +3 -1
- package/dist/primitives/sheet.d.ts.map +1 -1
- package/dist/primitives/sheet.js +12 -6
- package/dist/primitives/sheet.js.map +1 -1
- package/dist/primitives/skeleton.js +1 -1
- package/dist/primitives/skeleton.js.map +1 -1
- package/dist/primitives/switch.d.ts.map +1 -1
- package/dist/primitives/switch.js +2 -1
- package/dist/primitives/switch.js.map +1 -1
- package/dist/primitives/tabs.d.ts.map +1 -1
- package/dist/primitives/tabs.js +5 -3
- package/dist/primitives/tabs.js.map +1 -1
- package/dist/render-cases.d.ts.map +1 -1
- package/dist/render-cases.js +20 -2
- package/dist/render-cases.js.map +1 -1
- package/dist/render-scan.d.ts +25 -0
- package/dist/render-scan.d.ts.map +1 -0
- package/dist/render-scan.js +66 -0
- package/dist/render-scan.js.map +1 -0
- package/dist/theme/cd-tokens.d.ts +66 -0
- package/dist/theme/cd-tokens.d.ts.map +1 -0
- package/dist/theme/cd-tokens.js +64 -0
- package/dist/theme/cd-tokens.js.map +1 -0
- package/dist/theme/create-theme.d.ts +196 -39
- package/dist/theme/create-theme.d.ts.map +1 -1
- package/dist/theme/create-theme.js +189 -13
- package/dist/theme/create-theme.js.map +1 -1
- package/dist/theme/fonts.d.ts +38 -0
- package/dist/theme/fonts.d.ts.map +1 -0
- package/dist/theme/fonts.js +56 -0
- package/dist/theme/fonts.js.map +1 -0
- package/dist/theme/host-fonts.d.ts +39 -0
- package/dist/theme/host-fonts.d.ts.map +1 -0
- package/dist/theme/host-fonts.js +44 -0
- package/dist/theme/host-fonts.js.map +1 -0
- package/dist/theme/index.d.ts +8 -1
- package/dist/theme/index.d.ts.map +1 -1
- package/dist/theme/index.js +5 -1
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/portal-container.d.ts +16 -0
- package/dist/theme/portal-container.d.ts.map +1 -0
- package/dist/theme/portal-container.js +19 -0
- package/dist/theme/portal-container.js.map +1 -0
- package/dist/theme/presets.d.ts +24 -9
- package/dist/theme/presets.d.ts.map +1 -1
- package/dist/theme/presets.js +44 -11
- package/dist/theme/presets.js.map +1 -1
- package/dist/theme/theme-provider.d.ts +27 -9
- package/dist/theme/theme-provider.d.ts.map +1 -1
- package/dist/theme/theme-provider.js +42 -20
- package/dist/theme/theme-provider.js.map +1 -1
- package/package.json +9 -4
- package/src/app/builder/BuilderBanners.tsx +26 -13
- package/src/app/builder/CanvasRow.tsx +22 -21
- package/src/app/builder/CatalogueSheet.tsx +16 -14
- package/src/app/builder/EmptyHint.tsx +16 -11
- package/src/app/builder/LayoutSurface.tsx +7 -7
- package/src/app/builder/PipelineStrip.tsx +26 -17
- package/src/app/builder/SaveDialog.tsx +11 -12
- package/src/app/builder/Toolbar.tsx +19 -7
- package/src/app/builder/Workspace.tsx +5 -3
- package/src/app/builder/builder-feedback.test.tsx +75 -0
- package/src/app/builder/builder-model.test.ts +5 -0
- package/src/app/builder/builder-model.ts +5 -4
- package/src/app/builder/labels.ts +15 -121
- package/src/app/builder/status-chip.tsx +8 -4
- package/src/app/builder/use-builder-catalogue.ts +6 -0
- package/src/app/builder/use-save-dashboard.ts +5 -0
- package/src/app/index.ts +8 -1
- package/src/app/layout-builder.tsx +23 -3
- package/src/app/mcp-app-view-chrome.test.tsx +347 -14
- package/src/app/mcp-app-view-chrome.tsx +278 -100
- package/src/app/mcp-app-view-states.tsx +100 -0
- package/src/app/mcp-app-view.test.tsx +258 -0
- package/src/app/mcp-app-view.tsx +292 -185
- package/src/app/mcp-toolkit-app.tsx +31 -7
- package/src/app/use-view-lifecycle.ts +116 -0
- package/src/app/view-lifecycle.test.ts +227 -0
- package/src/app/view-lifecycle.ts +174 -0
- package/src/app/widget-props-sheet.tsx +13 -26
- package/src/app/widget-renderer.render.test.tsx +131 -0
- package/src/app/widget-renderer.tsx +136 -38
- package/src/components/DrillButton.tsx +2 -1
- package/src/components/EmptyState.tsx +81 -0
- package/src/components/ErrorState.tsx +182 -0
- package/src/components/FilterBar.tsx +9 -6
- package/src/components/GroupCard.tsx +3 -2
- package/src/components/KpiGrid.tsx +75 -44
- package/src/components/ListFooter.tsx +17 -6
- package/src/components/Pills.tsx +20 -11
- package/src/components/Toast.tsx +188 -0
- package/src/components/WidgetHeader.tsx +1 -1
- package/src/components/feedback-states.test.tsx +225 -0
- package/src/components/tone-figures.test.tsx +54 -0
- package/src/fonts.css +19 -0
- package/src/globals.css +9 -165
- package/src/hooks/use-tool-result-recovery.test.ts +25 -1
- package/src/hooks/use-tool-result-recovery.ts +34 -6
- package/src/hooks/use-view-data.test.tsx +60 -0
- package/src/hooks/use-view-data.ts +12 -1
- package/src/i18n-coverage.test.tsx +323 -0
- package/src/index.ts +57 -5
- package/src/lib/focus-ring.ts +20 -0
- package/src/lib/icon-stroke.test.ts +59 -0
- package/src/lib/icon-stroke.ts +13 -0
- package/src/lib/toast-model.test.ts +68 -0
- package/src/lib/toast-model.ts +63 -0
- package/src/lib/tone-utils.test.ts +47 -14
- package/src/lib/tone-utils.ts +61 -13
- package/src/locale/index.ts +13 -1
- package/src/locale/locale-provider.test.tsx +87 -0
- package/src/locale/locale-provider.tsx +90 -3
- package/src/locale/message-types.ts +221 -0
- package/src/locale/messages-de.ts +159 -0
- package/src/locale/messages-en.ts +157 -0
- package/src/locale/toolkit-messages.test.ts +216 -0
- package/src/locale/toolkit-messages.ts +82 -0
- package/src/no-hardcoded-copy.test.ts +432 -0
- package/src/primitives/alert.tsx +4 -2
- package/src/primitives/badge.tsx +14 -7
- package/src/primitives/button.tsx +13 -8
- package/src/primitives/card.tsx +4 -4
- package/src/primitives/dialog.tsx +32 -7
- package/src/primitives/input.tsx +5 -3
- package/src/primitives/scroll-area.tsx +1 -1
- package/src/primitives/select.tsx +6 -3
- package/src/primitives/sheet.tsx +26 -6
- package/src/primitives/skeleton.tsx +1 -1
- package/src/primitives/switch.tsx +4 -2
- package/src/primitives/tabs.tsx +9 -6
- package/src/render-cases.tsx +45 -1
- package/src/render-scan.ts +75 -0
- package/src/theme/cd-tokens.generated.css +48 -0
- package/src/theme/cd-tokens.test.ts +107 -0
- package/src/theme/cd-tokens.ts +67 -0
- package/src/theme/create-theme.test.ts +243 -5
- package/src/theme/create-theme.ts +313 -41
- package/src/theme/fonts.test.ts +85 -0
- package/src/theme/fonts.ts +72 -0
- package/src/theme/host-fonts.test.ts +135 -0
- package/src/theme/host-fonts.ts +52 -0
- package/src/theme/index.ts +15 -1
- package/src/theme/portal-container.ts +20 -0
- package/src/theme/presets.ts +44 -11
- package/src/theme/theme-css.test.ts +568 -0
- package/src/theme/theme-provider.test.tsx +82 -0
- package/src/theme/theme-provider.tsx +60 -24
- package/src/theme.css +314 -0
- package/ui-catalog.json +344 -17
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { useCallback, useEffect, useReducer, useRef, useState, type RefObject } from "react"
|
|
2
|
+
import {
|
|
3
|
+
decodeViewResult,
|
|
4
|
+
errorMessage,
|
|
5
|
+
resolveViewLifecycle,
|
|
6
|
+
RETRY_IDLE,
|
|
7
|
+
retryFocusTarget,
|
|
8
|
+
retryReducer,
|
|
9
|
+
waitKey,
|
|
10
|
+
withSlowHint,
|
|
11
|
+
type RenderCallStatus,
|
|
12
|
+
type ViewLifecycle,
|
|
13
|
+
} from "./view-lifecycle.js"
|
|
14
|
+
|
|
15
|
+
export interface UseViewLifecycleOptions<T> {
|
|
16
|
+
hasViewData: boolean
|
|
17
|
+
callStatus: RenderCallStatus
|
|
18
|
+
cancelled: boolean
|
|
19
|
+
callError: unknown
|
|
20
|
+
recoveryFailed: boolean
|
|
21
|
+
/** The originating tool (`hostContext.toolInfo`); a retry needs it. */
|
|
22
|
+
toolName: string | undefined
|
|
23
|
+
toolInput: Record<string, unknown> | null | undefined
|
|
24
|
+
callTool: (name: string, args: Record<string, unknown>) => Promise<unknown>
|
|
25
|
+
isValid: (value: unknown) => value is T
|
|
26
|
+
/** Receives the payload of a successful retry. */
|
|
27
|
+
onResult: (data: T) => void
|
|
28
|
+
slowHintAfterMs: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Drives the {@link resolveViewLifecycle} state machine: owns the retry and
|
|
33
|
+
* the slow-hint timer, and returns the current phase plus `retry()`.
|
|
34
|
+
*/
|
|
35
|
+
export function useViewLifecycle<T>(options: UseViewLifecycleOptions<T>): {
|
|
36
|
+
lifecycle: ViewLifecycle
|
|
37
|
+
retry: () => void
|
|
38
|
+
} {
|
|
39
|
+
const { toolName, toolInput, callTool, isValid, onResult, slowHintAfterMs } = options
|
|
40
|
+
const [retryState, dispatch] = useReducer(retryReducer, RETRY_IDLE)
|
|
41
|
+
// The wait that went slow; a newer wait (another attempt) starts fresh.
|
|
42
|
+
const [slowWait, setSlowWait] = useState<string | null>(null)
|
|
43
|
+
|
|
44
|
+
const unslowed = resolveViewLifecycle({
|
|
45
|
+
hasViewData: options.hasViewData,
|
|
46
|
+
callStatus: options.callStatus,
|
|
47
|
+
cancelled: options.cancelled,
|
|
48
|
+
callErrorReason: errorMessage(options.callError),
|
|
49
|
+
recoveryFailed: options.recoveryFailed,
|
|
50
|
+
retryState,
|
|
51
|
+
slow: false,
|
|
52
|
+
canRetry: toolName !== undefined,
|
|
53
|
+
})
|
|
54
|
+
const currentWait = waitKey(unslowed, retryState)
|
|
55
|
+
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (currentWait === null) return
|
|
58
|
+
const timer = setTimeout(() => setSlowWait(currentWait), slowHintAfterMs)
|
|
59
|
+
return () => clearTimeout(timer)
|
|
60
|
+
}, [currentWait, slowHintAfterMs])
|
|
61
|
+
|
|
62
|
+
const lifecycle = withSlowHint(unslowed, slowWait, currentWait)
|
|
63
|
+
|
|
64
|
+
const retry = useCallback(() => {
|
|
65
|
+
if (toolName === undefined) return
|
|
66
|
+
dispatch({ type: "start" })
|
|
67
|
+
void callTool(toolName, toolInput ?? {}).then(
|
|
68
|
+
(result) => {
|
|
69
|
+
const outcome = decodeViewResult(result, isValid)
|
|
70
|
+
if (outcome.ok) {
|
|
71
|
+
onResult(outcome.data)
|
|
72
|
+
dispatch({ type: "succeeded" })
|
|
73
|
+
} else {
|
|
74
|
+
dispatch({ type: "failed", reason: outcome.reason })
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
(err: unknown) => dispatch({ type: "failed", reason: errorMessage(err) }),
|
|
78
|
+
)
|
|
79
|
+
}, [callTool, isValid, onResult, toolInput, toolName])
|
|
80
|
+
|
|
81
|
+
return { lifecycle, retry }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The retry button of the view's error state (the focus target after a failed retry). */
|
|
85
|
+
const RETRY_BUTTON = '[data-slot="error-state"] button'
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Keeps keyboard focus in the view across a user retry: the pressed button
|
|
89
|
+
* unmounts when the view starts waiting, so focus moves to the frame (a
|
|
90
|
+
* `tabIndex={-1}` container), then to the new retry button when the retry
|
|
91
|
+
* failed, or stays on the frame once the view rendered (see
|
|
92
|
+
* {@link retryFocusTarget}). Focus the user moved elsewhere meanwhile is left
|
|
93
|
+
* alone. Returns the frame ref and `retry` wrapped to start the hand-off.
|
|
94
|
+
*/
|
|
95
|
+
export function useRetryFocus(
|
|
96
|
+
phase: ViewLifecycle["phase"],
|
|
97
|
+
retry: () => void,
|
|
98
|
+
): { frameRef: RefObject<HTMLDivElement | null>; retry: () => void } {
|
|
99
|
+
const frameRef = useRef<HTMLDivElement | null>(null)
|
|
100
|
+
const owed = useRef(false)
|
|
101
|
+
const retryWithFocus = useCallback(() => {
|
|
102
|
+
owed.current = true
|
|
103
|
+
retry()
|
|
104
|
+
}, [retry])
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
const frame = frameRef.current
|
|
107
|
+
if (!owed.current || !frame) return
|
|
108
|
+
const { target, settled } = retryFocusTarget(phase)
|
|
109
|
+
if (settled) owed.current = false
|
|
110
|
+
const active = frame.ownerDocument.activeElement
|
|
111
|
+
if (active && active !== frame.ownerDocument.body && !frame.contains(active)) return
|
|
112
|
+
const button = target === "retry" ? frame.querySelector<HTMLElement>(RETRY_BUTTON) : null
|
|
113
|
+
;(button ?? frame).focus()
|
|
114
|
+
}, [phase])
|
|
115
|
+
return { frameRef, retry: retryWithFocus }
|
|
116
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import {
|
|
3
|
+
decodeViewResult,
|
|
4
|
+
DEFAULT_SLOW_HINT_MS,
|
|
5
|
+
errorMessage,
|
|
6
|
+
resolveViewLifecycle,
|
|
7
|
+
RETRY_IDLE,
|
|
8
|
+
retryFocusTarget,
|
|
9
|
+
retryReducer,
|
|
10
|
+
waitKey,
|
|
11
|
+
withSlowHint,
|
|
12
|
+
type RetryState,
|
|
13
|
+
type ViewLifecycleInput,
|
|
14
|
+
} from "./view-lifecycle.js"
|
|
15
|
+
|
|
16
|
+
/** The baseline: a rendering call still open on a host that knows the tool. */
|
|
17
|
+
const waiting: ViewLifecycleInput = {
|
|
18
|
+
hasViewData: false,
|
|
19
|
+
callStatus: "pending",
|
|
20
|
+
cancelled: false,
|
|
21
|
+
callErrorReason: undefined,
|
|
22
|
+
recoveryFailed: false,
|
|
23
|
+
retryState: RETRY_IDLE,
|
|
24
|
+
slow: false,
|
|
25
|
+
canRetry: true,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const at = (over: Partial<ViewLifecycleInput>) => resolveViewLifecycle({ ...waiting, ...over })
|
|
29
|
+
|
|
30
|
+
describe("resolveViewLifecycle (toolkit#176 state machine)", () => {
|
|
31
|
+
it("waits while the rendering call is open, slow once the hint delay passed", () => {
|
|
32
|
+
expect(at({})).toEqual({ phase: "waiting", slow: false })
|
|
33
|
+
expect(at({ slow: true })).toEqual({ phase: "waiting", slow: true })
|
|
34
|
+
// A latched-ready call without a complete payload (recovery in flight) still waits.
|
|
35
|
+
expect(at({ callStatus: "ready" })).toEqual({ phase: "waiting", slow: false })
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it("an isError result is an error with its reason — never an endless wait (K14)", () => {
|
|
39
|
+
expect(at({ callStatus: "error", callErrorReason: "[503] engine down" })).toEqual({
|
|
40
|
+
phase: "error",
|
|
41
|
+
reason: "[503] engine down",
|
|
42
|
+
canRetry: true,
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it("a failed stripped-result recovery is an error too (N22: a terminal state)", () => {
|
|
47
|
+
expect(at({ recoveryFailed: true })).toEqual({
|
|
48
|
+
phase: "error",
|
|
49
|
+
reason: undefined,
|
|
50
|
+
canRetry: true,
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it("offers a retry only when the originating tool is known", () => {
|
|
55
|
+
expect(at({ callStatus: "error", canRetry: false })).toMatchObject({
|
|
56
|
+
phase: "error",
|
|
57
|
+
canRetry: false,
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it("a latched non-view result nothing can recover is an error, not an endless wait", () => {
|
|
62
|
+
// No toolInfo: the recovery cannot re-run the tool, so the wait would never end.
|
|
63
|
+
expect(at({ callStatus: "ready", canRetry: false })).toEqual({
|
|
64
|
+
phase: "error",
|
|
65
|
+
reason: undefined,
|
|
66
|
+
canRetry: false,
|
|
67
|
+
})
|
|
68
|
+
// Still pending on such a host: the result may yet arrive.
|
|
69
|
+
expect(at({ callStatus: "pending", canRetry: false })).toEqual({
|
|
70
|
+
phase: "waiting",
|
|
71
|
+
slow: false,
|
|
72
|
+
})
|
|
73
|
+
// A running retry or a payload in hand still win.
|
|
74
|
+
expect(
|
|
75
|
+
at({ callStatus: "ready", canRetry: false, retryState: { status: "running", attempt: 1 } }),
|
|
76
|
+
).toEqual({ phase: "waiting", slow: false })
|
|
77
|
+
expect(at({ callStatus: "ready", canRetry: false, hasViewData: true })).toEqual({
|
|
78
|
+
phase: "result",
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it("a host cancellation is terminal and wins over the error status it latches as", () => {
|
|
83
|
+
expect(at({ cancelled: true, callStatus: "error", callErrorReason: "cancelled" })).toEqual({
|
|
84
|
+
phase: "cancelled",
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it("a running retry waits (with its own slow flag), a failed retry shows ITS reason", () => {
|
|
89
|
+
const running: RetryState = { status: "running", attempt: 1 }
|
|
90
|
+
expect(at({ callStatus: "error", retryState: running })).toEqual({
|
|
91
|
+
phase: "waiting",
|
|
92
|
+
slow: false,
|
|
93
|
+
})
|
|
94
|
+
expect(at({ callStatus: "error", retryState: running, slow: true })).toEqual({
|
|
95
|
+
phase: "waiting",
|
|
96
|
+
slow: true,
|
|
97
|
+
})
|
|
98
|
+
const failed: RetryState = { status: "failed", attempt: 1, reason: "still down" }
|
|
99
|
+
expect(at({ callStatus: "error", callErrorReason: "first", retryState: failed })).toEqual({
|
|
100
|
+
phase: "error",
|
|
101
|
+
reason: "still down",
|
|
102
|
+
canRetry: true,
|
|
103
|
+
})
|
|
104
|
+
expect(at({ cancelled: true, retryState: failed, canRetry: false })).toEqual({
|
|
105
|
+
phase: "error",
|
|
106
|
+
reason: "still down",
|
|
107
|
+
canRetry: false,
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it("a payload in hand always wins — the view renders", () => {
|
|
112
|
+
expect(at({ hasViewData: true, callStatus: "error", recoveryFailed: true })).toEqual({
|
|
113
|
+
phase: "result",
|
|
114
|
+
})
|
|
115
|
+
expect(at({ hasViewData: true, retryState: { status: "running", attempt: 2 } })).toEqual({
|
|
116
|
+
phase: "result",
|
|
117
|
+
})
|
|
118
|
+
expect(at({ hasViewData: true, cancelled: true })).toEqual({ phase: "result" })
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
describe("retryReducer", () => {
|
|
123
|
+
it("counts attempts: start runs the next one, failure keeps the count and the reason", () => {
|
|
124
|
+
const first = retryReducer(RETRY_IDLE, { type: "start" })
|
|
125
|
+
expect(first).toEqual({ status: "running", attempt: 1 })
|
|
126
|
+
const failed = retryReducer(first, { type: "failed", reason: "503" })
|
|
127
|
+
expect(failed).toEqual({ status: "failed", attempt: 1, reason: "503" })
|
|
128
|
+
const second = retryReducer(failed, { type: "start" })
|
|
129
|
+
expect(second).toEqual({ status: "running", attempt: 2 })
|
|
130
|
+
expect(retryReducer(second, { type: "succeeded" })).toEqual({ status: "idle", attempt: 2 })
|
|
131
|
+
expect(retryReducer(second, { type: "failed", reason: undefined })).toEqual({
|
|
132
|
+
status: "failed",
|
|
133
|
+
attempt: 2,
|
|
134
|
+
reason: undefined,
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
describe("waitKey", () => {
|
|
140
|
+
it("names each wait so the slow hint restarts per attempt and stops when not waiting", () => {
|
|
141
|
+
expect(waitKey({ phase: "waiting", slow: false }, RETRY_IDLE)).toBe("idle:0")
|
|
142
|
+
expect(waitKey({ phase: "waiting", slow: false }, { status: "running", attempt: 2 })).toBe(
|
|
143
|
+
"running:2",
|
|
144
|
+
)
|
|
145
|
+
expect(waitKey({ phase: "error", reason: undefined, canRetry: true }, RETRY_IDLE)).toBeNull()
|
|
146
|
+
expect(waitKey({ phase: "result" }, RETRY_IDLE)).toBeNull()
|
|
147
|
+
expect(waitKey({ phase: "cancelled" }, RETRY_IDLE)).toBeNull()
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it("waits 8 s before the slow hint by default", () => {
|
|
151
|
+
expect(DEFAULT_SLOW_HINT_MS).toBe(8000)
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
describe("withSlowHint", () => {
|
|
156
|
+
const waiting = { phase: "waiting", slow: false } as const
|
|
157
|
+
|
|
158
|
+
it("turns the current wait slow once ITS delay passed", () => {
|
|
159
|
+
expect(withSlowHint(waiting, "idle:0", "idle:0")).toEqual({ phase: "waiting", slow: true })
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it("keeps a new attempt and an unslowed wait without the hint", () => {
|
|
163
|
+
expect(withSlowHint(waiting, "idle:0", "running:1")).toBe(waiting)
|
|
164
|
+
expect(withSlowHint(waiting, null, "idle:0")).toBe(waiting)
|
|
165
|
+
expect(withSlowHint(waiting, null, null)).toBe(waiting)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it("never touches a phase that is not waiting", () => {
|
|
169
|
+
const error = { phase: "error", reason: "x", canRetry: true } as const
|
|
170
|
+
expect(withSlowHint(error, "idle:0", "idle:0")).toBe(error)
|
|
171
|
+
expect(withSlowHint({ phase: "result" }, "idle:0", "idle:0")).toEqual({ phase: "result" })
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
describe("retryFocusTarget", () => {
|
|
176
|
+
it("holds focus on the frame while the retry runs, then hands it on and settles", () => {
|
|
177
|
+
expect(retryFocusTarget("waiting")).toEqual({ target: "frame", settled: false })
|
|
178
|
+
expect(retryFocusTarget("error")).toEqual({ target: "retry", settled: true })
|
|
179
|
+
expect(retryFocusTarget("result")).toEqual({ target: "frame", settled: true })
|
|
180
|
+
expect(retryFocusTarget("cancelled")).toEqual({ target: "frame", settled: true })
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
describe("decodeViewResult", () => {
|
|
185
|
+
const isView = (v: unknown): v is { layout: unknown } =>
|
|
186
|
+
typeof v === "object" && v !== null && "layout" in v
|
|
187
|
+
|
|
188
|
+
it("returns the payload of a complete view", () => {
|
|
189
|
+
expect(decodeViewResult({ structuredContent: { layout: { rows: [] } } }, isView)).toEqual({
|
|
190
|
+
ok: true,
|
|
191
|
+
data: { layout: { rows: [] } },
|
|
192
|
+
})
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it("types a tool error as such, with the isError text as the raw reason", () => {
|
|
196
|
+
expect(decodeViewResult({ isError: true, content: [{ text: "[503] down" }] }, isView)).toEqual({
|
|
197
|
+
ok: false,
|
|
198
|
+
isError: true,
|
|
199
|
+
reason: "[503] down",
|
|
200
|
+
})
|
|
201
|
+
// An isError result without text is still a tool error.
|
|
202
|
+
expect(decodeViewResult({ isError: true, content: [{ text: "" }] }, isView)).toEqual({
|
|
203
|
+
ok: false,
|
|
204
|
+
isError: true,
|
|
205
|
+
reason: undefined,
|
|
206
|
+
})
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it("types a payload the shell cannot render as no view, without a reason", () => {
|
|
210
|
+
expect(decodeViewResult({ structuredContent: { other: 1 } }, isView)).toEqual({
|
|
211
|
+
ok: false,
|
|
212
|
+
isError: false,
|
|
213
|
+
reason: undefined,
|
|
214
|
+
})
|
|
215
|
+
})
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
describe("errorMessage", () => {
|
|
219
|
+
it("reads Error messages and strings, nothing else", () => {
|
|
220
|
+
expect(errorMessage(new Error("boom"))).toBe("boom")
|
|
221
|
+
expect(errorMessage(new Error(""))).toBeUndefined()
|
|
222
|
+
expect(errorMessage("plain")).toBe("plain")
|
|
223
|
+
expect(errorMessage("")).toBeUndefined()
|
|
224
|
+
expect(errorMessage({ message: "not an error" })).toBeUndefined()
|
|
225
|
+
expect(errorMessage(undefined)).toBeUndefined()
|
|
226
|
+
})
|
|
227
|
+
})
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `McpAppView` lifecycle as an explicit state machine (toolkit#176).
|
|
3
|
+
*
|
|
4
|
+
* Before the view payload exists the shell is in exactly one phase:
|
|
5
|
+
*
|
|
6
|
+
* waiting ──result──▶ result
|
|
7
|
+
* │ ╲
|
|
8
|
+
* │ ╲──tool error / failed recovery / unrecoverable result──▶ error ──retry──▶ waiting
|
|
9
|
+
* │
|
|
10
|
+
* └──host cancelled──▶ cancelled (terminal: the user stopped it)
|
|
11
|
+
*
|
|
12
|
+
* `waiting` carries a `slow` flag once loading outlasts the slow-hint delay.
|
|
13
|
+
* An `isError` result (or a failed call) is an `error`, never an endless
|
|
14
|
+
* skeleton, and a retry re-runs the originating tool once on the user's click.
|
|
15
|
+
* A latched result that is no view on a host that names no originating tool
|
|
16
|
+
* is an `error` too: nothing could recover it, so waiting would never end.
|
|
17
|
+
* Pure functions only: the hook in `use-view-lifecycle.ts` feeds them.
|
|
18
|
+
*/
|
|
19
|
+
import { parseToolResult } from "../lib/parse-tool-result.js"
|
|
20
|
+
|
|
21
|
+
/** `useToolContext().status` of the rendering call. */
|
|
22
|
+
export type RenderCallStatus = "pending" | "ready" | "error"
|
|
23
|
+
|
|
24
|
+
/** The user-triggered retry of the rendering call. */
|
|
25
|
+
export type RetryState =
|
|
26
|
+
| { status: "idle"; attempt: number }
|
|
27
|
+
| { status: "running"; attempt: number }
|
|
28
|
+
| { status: "failed"; attempt: number; reason: string | undefined }
|
|
29
|
+
|
|
30
|
+
export const RETRY_IDLE: RetryState = { status: "idle", attempt: 0 }
|
|
31
|
+
|
|
32
|
+
export type RetryAction =
|
|
33
|
+
{ type: "start" } | { type: "failed"; reason: string | undefined } | { type: "succeeded" }
|
|
34
|
+
|
|
35
|
+
/** start → running (next attempt); failed → failed with reason; succeeded → idle. */
|
|
36
|
+
export function retryReducer(state: RetryState, action: RetryAction): RetryState {
|
|
37
|
+
switch (action.type) {
|
|
38
|
+
case "start":
|
|
39
|
+
return { status: "running", attempt: state.attempt + 1 }
|
|
40
|
+
case "failed":
|
|
41
|
+
return { status: "failed", attempt: state.attempt, reason: action.reason }
|
|
42
|
+
case "succeeded":
|
|
43
|
+
return { status: "idle", attempt: state.attempt }
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ViewLifecycleInput {
|
|
48
|
+
/** A renderable payload is in hand (host-delivered, recovered or retried). */
|
|
49
|
+
hasViewData: boolean
|
|
50
|
+
/** Status of the original rendering call. */
|
|
51
|
+
callStatus: RenderCallStatus
|
|
52
|
+
/** The host cancelled the original rendering call. */
|
|
53
|
+
cancelled: boolean
|
|
54
|
+
/** Message of the original call's error, when it failed. */
|
|
55
|
+
callErrorReason: string | undefined
|
|
56
|
+
/** The automatic stripped-result recovery ran and failed. */
|
|
57
|
+
recoveryFailed: boolean
|
|
58
|
+
/** The user-triggered retry. */
|
|
59
|
+
retryState: RetryState
|
|
60
|
+
/** Loading has outlasted the slow-hint delay. */
|
|
61
|
+
slow: boolean
|
|
62
|
+
/** The originating tool is known, so a retry can re-run it. */
|
|
63
|
+
canRetry: boolean
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type ViewLifecycle =
|
|
67
|
+
| { phase: "result" }
|
|
68
|
+
| { phase: "waiting"; slow: boolean }
|
|
69
|
+
| { phase: "cancelled" }
|
|
70
|
+
| { phase: "error"; reason: string | undefined; canRetry: boolean }
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Resolve the single phase the view is in. Precedence: a payload always wins;
|
|
74
|
+
* a running retry is waiting; a failed retry is an error with ITS reason; a
|
|
75
|
+
* cancellation is terminal; a failed call, a failed recovery or a latched
|
|
76
|
+
* non-view result nothing can recover is an error; everything else is still
|
|
77
|
+
* waiting.
|
|
78
|
+
*/
|
|
79
|
+
export function resolveViewLifecycle(input: ViewLifecycleInput): ViewLifecycle {
|
|
80
|
+
if (input.hasViewData) return { phase: "result" }
|
|
81
|
+
const { retryState } = input
|
|
82
|
+
if (retryState.status === "running") return { phase: "waiting", slow: input.slow }
|
|
83
|
+
if (retryState.status === "failed") {
|
|
84
|
+
return { phase: "error", reason: retryState.reason, canRetry: input.canRetry }
|
|
85
|
+
}
|
|
86
|
+
if (input.cancelled) return { phase: "cancelled" }
|
|
87
|
+
if (input.callStatus === "error") {
|
|
88
|
+
return { phase: "error", reason: input.callErrorReason, canRetry: input.canRetry }
|
|
89
|
+
}
|
|
90
|
+
if (input.recoveryFailed) return { phase: "error", reason: undefined, canRetry: input.canRetry }
|
|
91
|
+
// A latched result without a renderable view: the recovery re-runs the
|
|
92
|
+
// originating tool, so it can only help when the host names that tool.
|
|
93
|
+
if (input.callStatus === "ready" && !input.canRetry) {
|
|
94
|
+
return { phase: "error", reason: undefined, canRetry: false }
|
|
95
|
+
}
|
|
96
|
+
return { phase: "waiting", slow: input.slow }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The phase with its slow flag: a wait turns slow only while the wait that
|
|
101
|
+
* outlasted the delay (`slowWait`, a {@link waitKey}) is still the current one,
|
|
102
|
+
* so a new attempt starts without the hint.
|
|
103
|
+
*/
|
|
104
|
+
export function withSlowHint(
|
|
105
|
+
lifecycle: ViewLifecycle,
|
|
106
|
+
slowWait: string | null,
|
|
107
|
+
currentWait: string | null,
|
|
108
|
+
): ViewLifecycle {
|
|
109
|
+
return lifecycle.phase === "waiting" && slowWait !== null && slowWait === currentWait
|
|
110
|
+
? { phase: "waiting", slow: true }
|
|
111
|
+
: lifecycle
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Where keyboard focus goes after the user pressed retry (the button unmounts
|
|
116
|
+
* as the view starts waiting): the view frame while waiting, the new retry
|
|
117
|
+
* button when the retry failed, the frame again once the view or a
|
|
118
|
+
* cancellation replaced the state. `settled` ends the hand-off.
|
|
119
|
+
*/
|
|
120
|
+
export function retryFocusTarget(phase: ViewLifecycle["phase"]): {
|
|
121
|
+
target: "frame" | "retry"
|
|
122
|
+
settled: boolean
|
|
123
|
+
} {
|
|
124
|
+
if (phase === "waiting") return { target: "frame", settled: false }
|
|
125
|
+
if (phase === "error") return { target: "retry", settled: true }
|
|
126
|
+
return { target: "frame", settled: true }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Identity of the current wait, so the slow-hint timer restarts for every new
|
|
131
|
+
* wait (the first load, then each retry attempt) and never for a finished one.
|
|
132
|
+
* `null` when the view is not waiting.
|
|
133
|
+
*/
|
|
134
|
+
export function waitKey(lifecycle: ViewLifecycle, retryState: RetryState): string | null {
|
|
135
|
+
return lifecycle.phase === "waiting" ? `${retryState.status}:${retryState.attempt}` : null
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Default delay before the waiting placeholder adds its slow hint. */
|
|
139
|
+
export const DEFAULT_SLOW_HINT_MS = 8000
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* A decoded view call (a retry or a refresh), typed so a failure never turns
|
|
143
|
+
* into UI text: `isError` tells a tool error (with its raw `reason`, for the
|
|
144
|
+
* Details disclosure or the console) from a payload that is no view.
|
|
145
|
+
*/
|
|
146
|
+
export type ViewResultOutcome<T> =
|
|
147
|
+
{ ok: true; data: T } | { ok: false; isError: boolean; reason: string | undefined }
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Decode a view call's result: the payload when it is a complete view,
|
|
151
|
+
* otherwise a failure — a tool error with its `isError` text, or a payload
|
|
152
|
+
* the shell cannot render (no reason).
|
|
153
|
+
*/
|
|
154
|
+
export function decodeViewResult<T>(
|
|
155
|
+
result: unknown,
|
|
156
|
+
isValid: (value: unknown) => value is T,
|
|
157
|
+
): ViewResultOutcome<T> {
|
|
158
|
+
let decoded: unknown
|
|
159
|
+
try {
|
|
160
|
+
decoded = parseToolResult<unknown>(result)
|
|
161
|
+
} catch (err) {
|
|
162
|
+
return { ok: false, isError: true, reason: errorMessage(err) }
|
|
163
|
+
}
|
|
164
|
+
return isValid(decoded)
|
|
165
|
+
? { ok: true, data: decoded }
|
|
166
|
+
: { ok: false, isError: false, reason: undefined }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** A displayable message for anything thrown. */
|
|
170
|
+
export function errorMessage(err: unknown): string | undefined {
|
|
171
|
+
if (err instanceof Error) return err.message || undefined
|
|
172
|
+
if (typeof err === "string") return err || undefined
|
|
173
|
+
return undefined
|
|
174
|
+
}
|
|
@@ -16,6 +16,8 @@ import {
|
|
|
16
16
|
SheetTitle,
|
|
17
17
|
} from "../primitives/sheet.js"
|
|
18
18
|
import { Switch } from "../primitives/switch.js"
|
|
19
|
+
import { useToolkitMessages } from "../locale/locale-provider.js"
|
|
20
|
+
import type { PropsSheetMessages } from "../locale/toolkit-messages.js"
|
|
19
21
|
|
|
20
22
|
/**
|
|
21
23
|
* Tiny JSON-Schema-driven editor for a layout cell's `props`. The
|
|
@@ -48,25 +50,8 @@ interface JsonSchemaObject {
|
|
|
48
50
|
required?: string[]
|
|
49
51
|
}
|
|
50
52
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
description?: string
|
|
54
|
-
apply?: string
|
|
55
|
-
cancel?: string
|
|
56
|
-
reset?: string
|
|
57
|
-
noProps?: string
|
|
58
|
-
unsetOption?: string
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const DEFAULT_LABELS: Required<WidgetPropsSheetLabels> = {
|
|
62
|
-
title: "Configure widget",
|
|
63
|
-
description: "Set per-instance props for this widget.",
|
|
64
|
-
apply: "Apply",
|
|
65
|
-
cancel: "Cancel",
|
|
66
|
-
reset: "Reset",
|
|
67
|
-
noProps: "This widget takes no per-instance props.",
|
|
68
|
-
unsetOption: "(unset)",
|
|
69
|
-
}
|
|
53
|
+
/** Override strings of the sheet; each defaults to the locale's `propsSheet` catalog. */
|
|
54
|
+
export type WidgetPropsSheetLabels = Partial<PropsSheetMessages>
|
|
70
55
|
|
|
71
56
|
const UNSET_SENTINEL = "__unset__"
|
|
72
57
|
|
|
@@ -89,7 +74,8 @@ export function WidgetPropsSheet({
|
|
|
89
74
|
onApply,
|
|
90
75
|
labels,
|
|
91
76
|
}: WidgetPropsSheetProps) {
|
|
92
|
-
const
|
|
77
|
+
const messages = useToolkitMessages()
|
|
78
|
+
const L = { ...messages.propsSheet, ...labels }
|
|
93
79
|
const obj = (schema ?? {}) as JsonSchemaObject
|
|
94
80
|
const [draft, setDraft] = useState<Record<string, unknown>>(value ?? {})
|
|
95
81
|
|
|
@@ -121,9 +107,9 @@ export function WidgetPropsSheet({
|
|
|
121
107
|
<SheetContent className="w-[400px] sm:max-w-md">
|
|
122
108
|
<SheetHeader>
|
|
123
109
|
<SheetTitle>{L.title}</SheetTitle>
|
|
124
|
-
<SheetDescription>
|
|
125
|
-
<span
|
|
126
|
-
{
|
|
110
|
+
<SheetDescription className="flex flex-col items-start gap-1">
|
|
111
|
+
<span>{L.description}</span>
|
|
112
|
+
<span className="bg-muted rounded px-1 font-mono text-xs">{widgetId}</span>
|
|
127
113
|
</SheetDescription>
|
|
128
114
|
</SheetHeader>
|
|
129
115
|
<div className="flex flex-col gap-4 px-4 py-4">
|
|
@@ -137,6 +123,7 @@ export function WidgetPropsSheet({
|
|
|
137
123
|
prop={prop}
|
|
138
124
|
value={draft[key]}
|
|
139
125
|
unsetLabel={L.unsetOption}
|
|
126
|
+
unsupportedLabel={L.unsupportedType}
|
|
140
127
|
onChange={(v) =>
|
|
141
128
|
setDraft((prev) => {
|
|
142
129
|
if (v === undefined) {
|
|
@@ -172,12 +159,14 @@ function PropertyField({
|
|
|
172
159
|
prop,
|
|
173
160
|
value,
|
|
174
161
|
unsetLabel,
|
|
162
|
+
unsupportedLabel,
|
|
175
163
|
onChange,
|
|
176
164
|
}: {
|
|
177
165
|
name: string
|
|
178
166
|
prop: JsonSchemaProperty
|
|
179
167
|
value: unknown
|
|
180
168
|
unsetLabel: string
|
|
169
|
+
unsupportedLabel: (type: string) => string
|
|
181
170
|
onChange: (v: unknown) => void
|
|
182
171
|
}) {
|
|
183
172
|
const id = `prop-${name}`
|
|
@@ -255,9 +244,7 @@ function PropertyField({
|
|
|
255
244
|
|
|
256
245
|
return (
|
|
257
246
|
<FieldShell id={id} name={name} description={prop.description}>
|
|
258
|
-
<p className="text-muted-foreground text-xs italic">
|
|
259
|
-
Unsupported type "{prop.type ?? "(none)"}" — edit raw JSON to set this field.
|
|
260
|
-
</p>
|
|
247
|
+
<p className="text-muted-foreground text-xs italic">{unsupportedLabel(prop.type ?? "?")}</p>
|
|
261
248
|
</FieldShell>
|
|
262
249
|
)
|
|
263
250
|
}
|