@miragon/mcp-toolkit-ui 1.1.0 → 2.0.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/dist/app/builder/BuilderBanners.d.ts +19 -0
- package/dist/app/builder/BuilderBanners.d.ts.map +1 -0
- package/dist/app/builder/BuilderBanners.js +22 -0
- package/dist/app/builder/BuilderBanners.js.map +1 -0
- package/dist/app/builder/CanvasRow.d.ts +26 -0
- package/dist/app/builder/CanvasRow.d.ts.map +1 -0
- package/dist/app/builder/CanvasRow.js +98 -0
- package/dist/app/builder/CanvasRow.js.map +1 -0
- package/dist/app/builder/CellPropsSheet.d.ts +19 -0
- package/dist/app/builder/CellPropsSheet.d.ts.map +1 -0
- package/dist/app/builder/CellPropsSheet.js +28 -0
- package/dist/app/builder/CellPropsSheet.js.map +1 -0
- package/dist/app/builder/EmptyHint.d.ts +6 -0
- package/dist/app/builder/EmptyHint.d.ts.map +1 -0
- package/dist/app/builder/EmptyHint.js +10 -0
- package/dist/app/builder/EmptyHint.js.map +1 -0
- package/dist/app/builder/LayoutSurface.d.ts +27 -0
- package/dist/app/builder/LayoutSurface.d.ts.map +1 -0
- package/dist/app/builder/LayoutSurface.js +45 -0
- package/dist/app/builder/LayoutSurface.js.map +1 -0
- package/dist/app/builder/Workspace.d.ts +9 -3
- package/dist/app/builder/Workspace.d.ts.map +1 -1
- package/dist/app/builder/Workspace.js +3 -97
- package/dist/app/builder/Workspace.js.map +1 -1
- package/dist/app/builder/builder-reducer.d.ts.map +1 -1
- package/dist/app/builder/builder-reducer.js +176 -164
- package/dist/app/builder/builder-reducer.js.map +1 -1
- package/dist/app/builder/use-builder-catalogue.d.ts +33 -0
- package/dist/app/builder/use-builder-catalogue.d.ts.map +1 -0
- package/dist/app/builder/use-builder-catalogue.js +155 -0
- package/dist/app/builder/use-builder-catalogue.js.map +1 -0
- package/dist/app/builder/use-key-step-entries.d.ts +22 -0
- package/dist/app/builder/use-key-step-entries.d.ts.map +1 -0
- package/dist/app/builder/use-key-step-entries.js +46 -0
- package/dist/app/builder/use-key-step-entries.js.map +1 -0
- package/dist/app/builder/use-save-dashboard.d.ts +33 -0
- package/dist/app/builder/use-save-dashboard.d.ts.map +1 -0
- package/dist/app/builder/use-save-dashboard.js +78 -0
- package/dist/app/builder/use-save-dashboard.js.map +1 -0
- package/dist/app/layout-builder.d.ts.map +1 -1
- package/dist/app/layout-builder.js +46 -277
- package/dist/app/layout-builder.js.map +1 -1
- package/dist/app/mcp-app-view-chrome.d.ts +77 -0
- package/dist/app/mcp-app-view-chrome.d.ts.map +1 -0
- package/dist/app/mcp-app-view-chrome.js +53 -0
- package/dist/app/mcp-app-view-chrome.js.map +1 -0
- package/dist/app/mcp-app-view.d.ts +3 -9
- package/dist/app/mcp-app-view.d.ts.map +1 -1
- package/dist/app/mcp-app-view.js +4 -31
- package/dist/app/mcp-app-view.js.map +1 -1
- package/dist/render-cases-primitives.d.ts +17 -0
- package/dist/render-cases-primitives.d.ts.map +1 -0
- package/dist/render-cases-primitives.js +88 -0
- package/dist/render-cases-primitives.js.map +1 -0
- package/dist/render-cases.d.ts +4 -0
- package/dist/render-cases.d.ts.map +1 -0
- package/dist/render-cases.js +140 -0
- package/dist/render-cases.js.map +1 -0
- package/package.json +10 -5
- package/src/app/builder/BuilderBanners.tsx +61 -0
- package/src/app/builder/CanvasRow.tsx +316 -0
- package/src/app/builder/CellPropsSheet.tsx +47 -0
- package/src/app/builder/EmptyHint.tsx +29 -0
- package/src/app/builder/LayoutSurface.tsx +140 -0
- package/src/app/builder/Workspace.tsx +33 -342
- package/src/app/builder/builder-reducer.ts +204 -170
- package/src/app/builder/use-builder-catalogue.ts +187 -0
- package/src/app/builder/use-key-step-entries.ts +60 -0
- package/src/app/builder/use-save-dashboard.ts +97 -0
- package/src/app/layout-builder.tsx +92 -497
- package/src/app/mcp-app-view-chrome.tsx +204 -0
- package/src/app/mcp-app-view.tsx +32 -140
- package/src/lib/parse-tool-result.property.test.ts +90 -0
- package/src/render-cases-primitives.tsx +255 -0
- package/src/render-cases.tsx +239 -0
- package/src/render-smoke.test.tsx +100 -0
- package/src/ui-catalog.test.ts +52 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Presentational chrome for `McpAppView` — the toolbar, the pipeline error
|
|
3
|
+
* list, and the build/render body — plus the view-envelope types they share.
|
|
4
|
+
* Split out of `mcp-app-view.tsx` so the shell component keeps only the
|
|
5
|
+
* host-sync and state logic while this file stays hook-free.
|
|
6
|
+
*/
|
|
7
|
+
import { Maximize2, Minimize2, Pencil, RefreshCw } from "lucide-react"
|
|
8
|
+
import type { LayoutConfig, PipelineStepRef } from "@miragon/mcp-toolkit-core"
|
|
9
|
+
import { LayoutBuilder, type LayoutBuilderProps } from "./layout-builder.js"
|
|
10
|
+
import { WidgetRenderer, type WidgetComponent } from "./widget-renderer.js"
|
|
11
|
+
|
|
12
|
+
export interface McpAppViewLabels {
|
|
13
|
+
loading?: string
|
|
14
|
+
refresh?: string
|
|
15
|
+
refreshing?: string
|
|
16
|
+
enterFullscreen?: string
|
|
17
|
+
exitFullscreen?: string
|
|
18
|
+
build?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const DEFAULT_LABELS: Required<McpAppViewLabels> = {
|
|
22
|
+
loading: "Waiting for pipeline result...",
|
|
23
|
+
refresh: "Refresh",
|
|
24
|
+
refreshing: "Loading...",
|
|
25
|
+
enterFullscreen: "Fullscreen",
|
|
26
|
+
exitFullscreen: "Collapse",
|
|
27
|
+
build: "Build",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface RefreshParams {
|
|
31
|
+
keys?: Record<string, unknown>
|
|
32
|
+
steps?: PipelineStepRef[]
|
|
33
|
+
layout?: LayoutConfig
|
|
34
|
+
title?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ViewData {
|
|
38
|
+
_refreshParams?: RefreshParams
|
|
39
|
+
title?: string
|
|
40
|
+
context: {
|
|
41
|
+
keys: Record<string, unknown>
|
|
42
|
+
stepIds: string[]
|
|
43
|
+
stepData: Record<
|
|
44
|
+
string,
|
|
45
|
+
{ data: unknown; keys: Record<string, unknown>; _app: string; _dataType: string }
|
|
46
|
+
>
|
|
47
|
+
errors: { stepId: string; reason: string }[]
|
|
48
|
+
}
|
|
49
|
+
layout?: LayoutConfig
|
|
50
|
+
/**
|
|
51
|
+
* Whether the server's in-iframe builder platform is usable (set by
|
|
52
|
+
* `render-view` from `createFrameworkApp`'s `app.builder`). The shell gates
|
|
53
|
+
* its Build affordance on this so the edit button never appears against a
|
|
54
|
+
* server whose `get-builder-catalogue` tool isn't registered.
|
|
55
|
+
*/
|
|
56
|
+
builderAvailable?: boolean
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A renderable view payload: the envelope `render-view`/`show_*` tools emit
|
|
61
|
+
* as `structuredContent`. Module-level so it is referentially stable as the
|
|
62
|
+
* recovery hook's `isValid` dependency.
|
|
63
|
+
*/
|
|
64
|
+
export function isCompleteViewData(value: unknown): value is ViewData {
|
|
65
|
+
const v = value as ViewData | null | undefined
|
|
66
|
+
return Boolean(v?.context && v?.layout)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// -------------------------------------------------------------------------- //
|
|
70
|
+
// Toolbar
|
|
71
|
+
// -------------------------------------------------------------------------- //
|
|
72
|
+
|
|
73
|
+
export interface ViewToolbarProps {
|
|
74
|
+
/** The toolbar hides entirely while build mode owns the surface. */
|
|
75
|
+
hidden: boolean
|
|
76
|
+
viewData: ViewData
|
|
77
|
+
builderAvailable: boolean
|
|
78
|
+
isFullscreen: boolean
|
|
79
|
+
isRefreshing: boolean
|
|
80
|
+
labels: Required<McpAppViewLabels>
|
|
81
|
+
onRefresh: () => Promise<void>
|
|
82
|
+
onEnterBuildMode: () => void
|
|
83
|
+
onToggleFullscreen: () => Promise<void>
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function ViewToolbar({
|
|
87
|
+
hidden,
|
|
88
|
+
viewData,
|
|
89
|
+
builderAvailable,
|
|
90
|
+
isFullscreen,
|
|
91
|
+
isRefreshing,
|
|
92
|
+
labels,
|
|
93
|
+
onRefresh,
|
|
94
|
+
onEnterBuildMode,
|
|
95
|
+
onToggleFullscreen,
|
|
96
|
+
}: ViewToolbarProps) {
|
|
97
|
+
if (hidden) return null
|
|
98
|
+
return (
|
|
99
|
+
<div className="mb-4 flex items-center justify-between">
|
|
100
|
+
{viewData.title && <h2 className="text-xl font-bold">{viewData.title}</h2>}
|
|
101
|
+
<div className="ml-auto flex items-center gap-2">
|
|
102
|
+
{viewData._refreshParams && viewData.layout && (
|
|
103
|
+
<button
|
|
104
|
+
onClick={() => {
|
|
105
|
+
void onRefresh()
|
|
106
|
+
}}
|
|
107
|
+
disabled={isRefreshing}
|
|
108
|
+
className="hover:bg-accent hover:text-accent-foreground inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm font-medium transition-colors disabled:opacity-50"
|
|
109
|
+
>
|
|
110
|
+
<RefreshCw
|
|
111
|
+
aria-hidden="true"
|
|
112
|
+
className={`h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}
|
|
113
|
+
/>
|
|
114
|
+
{isRefreshing ? labels.refreshing : labels.refresh}
|
|
115
|
+
</button>
|
|
116
|
+
)}
|
|
117
|
+
{builderAvailable && viewData._refreshParams && (
|
|
118
|
+
<button
|
|
119
|
+
onClick={onEnterBuildMode}
|
|
120
|
+
className="hover:bg-accent hover:text-accent-foreground inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm font-medium transition-colors"
|
|
121
|
+
>
|
|
122
|
+
<Pencil className="h-4 w-4" />
|
|
123
|
+
{labels.build}
|
|
124
|
+
</button>
|
|
125
|
+
)}
|
|
126
|
+
<button
|
|
127
|
+
onClick={() => {
|
|
128
|
+
void onToggleFullscreen()
|
|
129
|
+
}}
|
|
130
|
+
className="hover:bg-accent hover:text-accent-foreground inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm font-medium transition-colors"
|
|
131
|
+
>
|
|
132
|
+
{isFullscreen ? (
|
|
133
|
+
<Minimize2 aria-hidden="true" className="h-4 w-4" />
|
|
134
|
+
) : (
|
|
135
|
+
<Maximize2 aria-hidden="true" className="h-4 w-4" />
|
|
136
|
+
)}
|
|
137
|
+
{isFullscreen ? labels.exitFullscreen : labels.enterFullscreen}
|
|
138
|
+
</button>
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// -------------------------------------------------------------------------- //
|
|
145
|
+
// Pipeline error list
|
|
146
|
+
// -------------------------------------------------------------------------- //
|
|
147
|
+
|
|
148
|
+
export function StepErrors({
|
|
149
|
+
hidden,
|
|
150
|
+
errors,
|
|
151
|
+
}: {
|
|
152
|
+
/** Build mode surfaces its own diagnostics; the shell list hides. */
|
|
153
|
+
hidden: boolean
|
|
154
|
+
errors: ViewData["context"]["errors"]
|
|
155
|
+
}) {
|
|
156
|
+
if (hidden || errors.length === 0) return null
|
|
157
|
+
return (
|
|
158
|
+
<div className="mb-4 flex flex-col gap-2">
|
|
159
|
+
{errors.map((err) => (
|
|
160
|
+
<div key={err.stepId} className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm">
|
|
161
|
+
<strong>{err.stepId}:</strong> {err.reason}
|
|
162
|
+
</div>
|
|
163
|
+
))}
|
|
164
|
+
</div>
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// -------------------------------------------------------------------------- //
|
|
169
|
+
// Body: LayoutBuilder in build mode, WidgetRenderer otherwise
|
|
170
|
+
// -------------------------------------------------------------------------- //
|
|
171
|
+
|
|
172
|
+
export interface ViewBodyProps {
|
|
173
|
+
buildMode: boolean
|
|
174
|
+
viewData: ViewData
|
|
175
|
+
widgets: Record<string, WidgetComponent>
|
|
176
|
+
callTool: (name: string, args: object) => Promise<unknown>
|
|
177
|
+
onExit: LayoutBuilderProps["onExit"]
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function ViewBody({ buildMode, viewData, widgets, callTool, onExit }: ViewBodyProps) {
|
|
181
|
+
if (buildMode) {
|
|
182
|
+
return (
|
|
183
|
+
<LayoutBuilder
|
|
184
|
+
initialLayout={viewData.layout}
|
|
185
|
+
title={viewData.title}
|
|
186
|
+
initialKeys={viewData._refreshParams?.keys}
|
|
187
|
+
initialSteps={viewData._refreshParams?.steps}
|
|
188
|
+
widgets={widgets}
|
|
189
|
+
callTool={callTool}
|
|
190
|
+
onExit={onExit}
|
|
191
|
+
/>
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
if (!viewData.layout) return null
|
|
195
|
+
return (
|
|
196
|
+
<WidgetRenderer
|
|
197
|
+
layout={viewData.layout}
|
|
198
|
+
keys={viewData.context.keys}
|
|
199
|
+
stepData={viewData.context.stepData}
|
|
200
|
+
errors={viewData.context.errors}
|
|
201
|
+
widgets={widgets}
|
|
202
|
+
/>
|
|
203
|
+
)
|
|
204
|
+
}
|
package/src/app/mcp-app-view.tsx
CHANGED
|
@@ -1,71 +1,23 @@
|
|
|
1
1
|
import { useState, useCallback } from "react"
|
|
2
2
|
import { useDisplayMode, useHostContext, useToolContext } from "mcp-use/react"
|
|
3
|
-
import { Maximize2, Minimize2, Pencil, RefreshCw } from "lucide-react"
|
|
4
3
|
import type { LayoutConfig, PipelineStepRef } from "@miragon/mcp-toolkit-core"
|
|
5
4
|
import { Skeleton } from "../primitives/skeleton.js"
|
|
6
5
|
import { AppQueryProvider, queryClient } from "../providers/query-provider.js"
|
|
7
6
|
import { useToolResultRecovery } from "../hooks/use-tool-result-recovery.js"
|
|
8
7
|
import { parseToolResult } from "../lib/parse-tool-result.js"
|
|
9
8
|
import { useHostBridge } from "./host-bridge.js"
|
|
10
|
-
import {
|
|
11
|
-
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_LABELS,
|
|
11
|
+
isCompleteViewData,
|
|
12
|
+
StepErrors,
|
|
13
|
+
ViewBody,
|
|
14
|
+
ViewToolbar,
|
|
15
|
+
type McpAppViewLabels,
|
|
16
|
+
type ViewData,
|
|
17
|
+
} from "./mcp-app-view-chrome.js"
|
|
18
|
+
import type { WidgetComponent } from "./widget-renderer.js"
|
|
12
19
|
|
|
13
|
-
export
|
|
14
|
-
loading?: string
|
|
15
|
-
refresh?: string
|
|
16
|
-
refreshing?: string
|
|
17
|
-
enterFullscreen?: string
|
|
18
|
-
exitFullscreen?: string
|
|
19
|
-
build?: string
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
const DEFAULT_LABELS: Required<McpAppViewLabels> = {
|
|
23
|
-
loading: "Waiting for pipeline result...",
|
|
24
|
-
refresh: "Refresh",
|
|
25
|
-
refreshing: "Loading...",
|
|
26
|
-
enterFullscreen: "Fullscreen",
|
|
27
|
-
exitFullscreen: "Collapse",
|
|
28
|
-
build: "Build",
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
interface RefreshParams {
|
|
32
|
-
keys?: Record<string, unknown>
|
|
33
|
-
steps?: PipelineStepRef[]
|
|
34
|
-
layout?: LayoutConfig
|
|
35
|
-
title?: string
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
interface ViewData {
|
|
39
|
-
_refreshParams?: RefreshParams
|
|
40
|
-
title?: string
|
|
41
|
-
context: {
|
|
42
|
-
keys: Record<string, unknown>
|
|
43
|
-
stepIds: string[]
|
|
44
|
-
stepData: Record<
|
|
45
|
-
string,
|
|
46
|
-
{ data: unknown; keys: Record<string, unknown>; _app: string; _dataType: string }
|
|
47
|
-
>
|
|
48
|
-
errors: { stepId: string; reason: string }[]
|
|
49
|
-
}
|
|
50
|
-
layout?: LayoutConfig
|
|
51
|
-
/**
|
|
52
|
-
* Whether the server's in-iframe builder platform is usable (set by
|
|
53
|
-
* `render-view` from `createFrameworkApp`'s `app.builder`). The shell gates
|
|
54
|
-
* its Build affordance on this so the edit button never appears against a
|
|
55
|
-
* server whose `get-builder-catalogue` tool isn't registered.
|
|
56
|
-
*/
|
|
57
|
-
builderAvailable?: boolean
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* A renderable view payload: the envelope `render-view`/`show_*` tools emit
|
|
62
|
-
* as `structuredContent`. Module-level so it is referentially stable as the
|
|
63
|
-
* recovery hook's `isValid` dependency.
|
|
64
|
-
*/
|
|
65
|
-
function isCompleteViewData(value: unknown): value is ViewData {
|
|
66
|
-
const v = value as ViewData | null | undefined
|
|
67
|
-
return Boolean(v?.context && v?.layout)
|
|
68
|
-
}
|
|
20
|
+
export type { McpAppViewLabels } from "./mcp-app-view-chrome.js"
|
|
69
21
|
|
|
70
22
|
export interface McpAppViewProps {
|
|
71
23
|
/**
|
|
@@ -264,7 +216,7 @@ export function McpAppView({
|
|
|
264
216
|
)
|
|
265
217
|
}
|
|
266
218
|
|
|
267
|
-
const
|
|
219
|
+
const isFullscreen = displayMode === "fullscreen"
|
|
268
220
|
|
|
269
221
|
// Follow the server's own signal by default: render-view reports
|
|
270
222
|
// `builderAvailable` (derived from `app.builder`), so the Build button only
|
|
@@ -281,95 +233,35 @@ export function McpAppView({
|
|
|
281
233
|
// `bootstrapView`'s auto-resize, so we let content dictate height and
|
|
282
234
|
// avoid setting overflowY (which would clip our own content inside a
|
|
283
235
|
// host that's already auto-sized to us).
|
|
284
|
-
...(
|
|
285
|
-
? { minHeight: "100vh", overflowY: "auto" as const }
|
|
286
|
-
: null),
|
|
236
|
+
...(isFullscreen ? { minHeight: "100vh", overflowY: "auto" as const } : null),
|
|
287
237
|
paddingTop: safeArea.top,
|
|
288
238
|
paddingRight: safeArea.right,
|
|
289
239
|
paddingBottom: safeArea.bottom,
|
|
290
240
|
paddingLeft: safeArea.left,
|
|
291
241
|
}}
|
|
292
242
|
>
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
>
|
|
305
|
-
<RefreshCw
|
|
306
|
-
aria-hidden="true"
|
|
307
|
-
className={`h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}
|
|
308
|
-
/>
|
|
309
|
-
{isRefreshing ? effectiveLabels.refreshing : effectiveLabels.refresh}
|
|
310
|
-
</button>
|
|
311
|
-
)}
|
|
312
|
-
{builderAvailable && viewData._refreshParams && (
|
|
313
|
-
<button
|
|
314
|
-
onClick={() => setBuildMode(true)}
|
|
315
|
-
className="hover:bg-accent hover:text-accent-foreground inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm font-medium transition-colors"
|
|
316
|
-
>
|
|
317
|
-
<Pencil className="h-4 w-4" />
|
|
318
|
-
{effectiveLabels.build}
|
|
319
|
-
</button>
|
|
320
|
-
)}
|
|
321
|
-
<button
|
|
322
|
-
onClick={() => {
|
|
323
|
-
void toggleFullscreen()
|
|
324
|
-
}}
|
|
325
|
-
className="hover:bg-accent hover:text-accent-foreground inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm font-medium transition-colors"
|
|
326
|
-
>
|
|
327
|
-
{displayMode === "fullscreen" ? (
|
|
328
|
-
<Minimize2 aria-hidden="true" className="h-4 w-4" />
|
|
329
|
-
) : (
|
|
330
|
-
<Maximize2 aria-hidden="true" className="h-4 w-4" />
|
|
331
|
-
)}
|
|
332
|
-
{displayMode === "fullscreen"
|
|
333
|
-
? effectiveLabels.exitFullscreen
|
|
334
|
-
: effectiveLabels.enterFullscreen}
|
|
335
|
-
</button>
|
|
336
|
-
</div>
|
|
337
|
-
</div>
|
|
338
|
-
)}
|
|
243
|
+
<ViewToolbar
|
|
244
|
+
hidden={buildMode}
|
|
245
|
+
viewData={viewData}
|
|
246
|
+
builderAvailable={builderAvailable}
|
|
247
|
+
isFullscreen={isFullscreen}
|
|
248
|
+
isRefreshing={isRefreshing}
|
|
249
|
+
labels={effectiveLabels}
|
|
250
|
+
onRefresh={refreshView}
|
|
251
|
+
onEnterBuildMode={() => setBuildMode(true)}
|
|
252
|
+
onToggleFullscreen={toggleFullscreen}
|
|
253
|
+
/>
|
|
339
254
|
|
|
340
|
-
{
|
|
341
|
-
<div className="mb-4 flex flex-col gap-2">
|
|
342
|
-
{viewData.context.errors.map((err) => (
|
|
343
|
-
<div
|
|
344
|
-
key={err.stepId}
|
|
345
|
-
className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm"
|
|
346
|
-
>
|
|
347
|
-
<strong>{err.stepId}:</strong> {err.reason}
|
|
348
|
-
</div>
|
|
349
|
-
))}
|
|
350
|
-
</div>
|
|
351
|
-
)}
|
|
255
|
+
<StepErrors hidden={buildMode} errors={viewData.context.errors} />
|
|
352
256
|
|
|
353
257
|
<AppQueryProvider callTool={callToolFn}>
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
callTool={callToolFn}
|
|
362
|
-
onExit={exitBuildMode}
|
|
363
|
-
/>
|
|
364
|
-
) : viewData.layout ? (
|
|
365
|
-
<WidgetRenderer
|
|
366
|
-
layout={viewData.layout}
|
|
367
|
-
keys={viewData.context.keys}
|
|
368
|
-
stepData={viewData.context.stepData}
|
|
369
|
-
errors={viewData.context.errors}
|
|
370
|
-
widgets={widgets}
|
|
371
|
-
/>
|
|
372
|
-
) : null}
|
|
258
|
+
<ViewBody
|
|
259
|
+
buildMode={buildMode}
|
|
260
|
+
viewData={viewData}
|
|
261
|
+
widgets={widgets}
|
|
262
|
+
callTool={callToolFn}
|
|
263
|
+
onExit={exitBuildMode}
|
|
264
|
+
/>
|
|
373
265
|
</AppQueryProvider>
|
|
374
266
|
</main>
|
|
375
267
|
)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import fc from "fast-check"
|
|
3
|
+
import { parseToolResult } from "./parse-tool-result.js"
|
|
4
|
+
|
|
5
|
+
// Deterministic PRNG: a property failure in CI must replay identically on the
|
|
6
|
+
// next run, never appear as a fresh random draw (FITNESS.md phase 5c).
|
|
7
|
+
fc.configureGlobal({ seed: 42, numRuns: 100 })
|
|
8
|
+
|
|
9
|
+
// CallToolResult-ish shapes: structuredContent × content arrays whose first
|
|
10
|
+
// text block is valid JSON about half the time and arbitrary text otherwise.
|
|
11
|
+
const textArb = fc.oneof(
|
|
12
|
+
fc.string(),
|
|
13
|
+
fc.jsonValue().map((value) => JSON.stringify(value)),
|
|
14
|
+
)
|
|
15
|
+
const contentArb = fc.oneof(
|
|
16
|
+
fc.constant([]),
|
|
17
|
+
fc.array(fc.record({ type: fc.constant("text"), text: textArb }, { requiredKeys: ["type"] }), {
|
|
18
|
+
maxLength: 2,
|
|
19
|
+
}),
|
|
20
|
+
)
|
|
21
|
+
const resultArb = fc.record(
|
|
22
|
+
{
|
|
23
|
+
isError: fc.constant(false),
|
|
24
|
+
structuredContent: fc.oneof(fc.constant(null), fc.jsonValue()),
|
|
25
|
+
content: contentArb,
|
|
26
|
+
},
|
|
27
|
+
{ requiredKeys: [] },
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
describe("parseToolResult (property)", () => {
|
|
31
|
+
// Bug class guarded: this is the single decode seam every widget, query hook
|
|
32
|
+
// and the app shell funnel through — a malformed host response crashing here
|
|
33
|
+
// would take down the whole widget render path instead of one data read.
|
|
34
|
+
it("never throws on arbitrary junk without a truthy isError", () => {
|
|
35
|
+
const junkArb = fc
|
|
36
|
+
.anything()
|
|
37
|
+
.filter((junk) => !(typeof junk === "object" && junk !== null && "isError" in junk))
|
|
38
|
+
fc.assert(
|
|
39
|
+
fc.property(junkArb, (junk) => {
|
|
40
|
+
expect(() => parseToolResult(junk)).not.toThrow()
|
|
41
|
+
}),
|
|
42
|
+
)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
// Bug class guarded: the structured-first precedence IS the 0.4 contract —
|
|
46
|
+
// a tool emitting both a text summary and structuredContent must decode to
|
|
47
|
+
// the data payload, never regress to the summary string; and every result
|
|
48
|
+
// must land on a documented outcome (structured, JSON text, verbatim text,
|
|
49
|
+
// raw result), never on undefined.
|
|
50
|
+
it("yields exactly the documented outcome, structured content first", () => {
|
|
51
|
+
fc.assert(
|
|
52
|
+
fc.property(resultArb, (result) => {
|
|
53
|
+
const out: unknown = parseToolResult(result)
|
|
54
|
+
const text = Array.isArray(result.content) ? result.content[0]?.text : undefined
|
|
55
|
+
if (result.structuredContent != null) {
|
|
56
|
+
expect(out).toBe(result.structuredContent)
|
|
57
|
+
} else if (text !== undefined) {
|
|
58
|
+
let decoded: unknown = text
|
|
59
|
+
try {
|
|
60
|
+
decoded = JSON.parse(text)
|
|
61
|
+
} catch {
|
|
62
|
+
// Non-JSON text is returned verbatim — that IS the documented outcome.
|
|
63
|
+
}
|
|
64
|
+
expect(out).toEqual(decoded)
|
|
65
|
+
} else {
|
|
66
|
+
expect(out).toBe(result)
|
|
67
|
+
}
|
|
68
|
+
}),
|
|
69
|
+
)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
// Bug class guarded: error results must surface as thrown Errors carrying
|
|
73
|
+
// the first text block, so TanStack Query error states (and try/catch in
|
|
74
|
+
// widgets) show the server's message instead of silently rendering junk.
|
|
75
|
+
it("throws the first text block for every isError result", () => {
|
|
76
|
+
fc.assert(
|
|
77
|
+
fc.property(resultArb, fc.string(), (result, message) => {
|
|
78
|
+
const errorResult = { ...result, isError: true, content: [{ type: "text", text: message }] }
|
|
79
|
+
let thrown: unknown
|
|
80
|
+
try {
|
|
81
|
+
parseToolResult(errorResult)
|
|
82
|
+
} catch (err) {
|
|
83
|
+
thrown = err
|
|
84
|
+
}
|
|
85
|
+
expect(thrown).toBeInstanceOf(Error)
|
|
86
|
+
expect((thrown as Error).message).toBe(message)
|
|
87
|
+
}),
|
|
88
|
+
)
|
|
89
|
+
})
|
|
90
|
+
})
|