@seed-ship/mcp-ui-solid 6.4.0 → 6.6.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/CHANGELOG.md +230 -0
- package/README.md +37 -0
- package/dist/adapters/connector.cjs +112 -0
- package/dist/adapters/connector.cjs.map +1 -0
- package/dist/adapters/connector.d.ts +71 -0
- package/dist/adapters/connector.d.ts.map +1 -0
- package/dist/adapters/connector.js +112 -0
- package/dist/adapters/connector.js.map +1 -0
- package/dist/adapters/index.d.ts +18 -0
- package/dist/adapters/index.d.ts.map +1 -0
- package/dist/adapters.cjs +6 -0
- package/dist/adapters.cjs.map +1 -0
- package/dist/adapters.d.cts +18 -0
- package/dist/adapters.d.ts +18 -0
- package/dist/adapters.js +6 -0
- package/dist/adapters.js.map +1 -0
- package/dist/components/ExpandableWrapper.cjs +24 -6
- package/dist/components/ExpandableWrapper.cjs.map +1 -1
- package/dist/components/ExpandableWrapper.d.ts.map +1 -1
- package/dist/components/ExpandableWrapper.js +24 -6
- package/dist/components/ExpandableWrapper.js.map +1 -1
- package/dist/components/FeedbackInline.cjs +6 -2
- package/dist/components/FeedbackInline.cjs.map +1 -1
- package/dist/components/FeedbackInline.d.ts +2 -2
- package/dist/components/FeedbackInline.d.ts.map +1 -1
- package/dist/components/FeedbackInline.js +7 -3
- package/dist/components/FeedbackInline.js.map +1 -1
- package/dist/components/PresentationFeedback.cjs +207 -0
- package/dist/components/PresentationFeedback.cjs.map +1 -0
- package/dist/components/PresentationFeedback.d.ts +113 -0
- package/dist/components/PresentationFeedback.d.ts.map +1 -0
- package/dist/components/PresentationFeedback.js +207 -0
- package/dist/components/PresentationFeedback.js.map +1 -0
- package/dist/components/StreamingUIRenderer.cjs +82 -195
- package/dist/components/StreamingUIRenderer.cjs.map +1 -1
- package/dist/components/StreamingUIRenderer.d.ts +25 -5
- package/dist/components/StreamingUIRenderer.d.ts.map +1 -1
- package/dist/components/StreamingUIRenderer.js +84 -197
- package/dist/components/StreamingUIRenderer.js.map +1 -1
- package/dist/components/UIResourceRenderer.cjs +40 -10
- package/dist/components/UIResourceRenderer.cjs.map +1 -1
- package/dist/components/UIResourceRenderer.d.ts +20 -0
- package/dist/components/UIResourceRenderer.d.ts.map +1 -1
- package/dist/components/UIResourceRenderer.js +42 -12
- package/dist/components/UIResourceRenderer.js.map +1 -1
- package/dist/components/index.d.ts +2 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components.cjs +3 -0
- package/dist/components.cjs.map +1 -1
- package/dist/components.d.cts +2 -0
- package/dist/components.d.ts +2 -0
- package/dist/components.js +3 -0
- package/dist/components.js.map +1 -1
- package/dist/context/MCPUIStringsContext.cjs +38 -0
- package/dist/context/MCPUIStringsContext.cjs.map +1 -0
- package/dist/context/MCPUIStringsContext.d.ts +95 -0
- package/dist/context/MCPUIStringsContext.d.ts.map +1 -0
- package/dist/context/MCPUIStringsContext.js +38 -0
- package/dist/context/MCPUIStringsContext.js.map +1 -0
- package/dist/index.cjs +12 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +8 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -1
- package/dist/mcp-ui-spec/dist/schemas.cjs +103 -0
- package/dist/mcp-ui-spec/dist/schemas.cjs.map +1 -1
- package/dist/mcp-ui-spec/dist/schemas.js +103 -0
- package/dist/mcp-ui-spec/dist/schemas.js.map +1 -1
- package/dist/utils/duplicate-mount-registry.cjs +27 -0
- package/dist/utils/duplicate-mount-registry.cjs.map +1 -0
- package/dist/utils/duplicate-mount-registry.d.ts +84 -0
- package/dist/utils/duplicate-mount-registry.d.ts.map +1 -0
- package/dist/utils/duplicate-mount-registry.js +27 -0
- package/dist/utils/duplicate-mount-registry.js.map +1 -0
- package/dist/utils/stable-key.cjs +41 -0
- package/dist/utils/stable-key.cjs.map +1 -0
- package/dist/utils/stable-key.d.ts +33 -0
- package/dist/utils/stable-key.d.ts.map +1 -0
- package/dist/utils/stable-key.js +41 -0
- package/dist/utils/stable-key.js.map +1 -0
- package/docs/briefs/ROADMAP-opendata-macro-mcpui.md +912 -0
- package/package.json +17 -5
- package/src/adapters/connector.test.ts +165 -0
- package/src/adapters/connector.ts +234 -0
- package/src/adapters/index.ts +24 -0
- package/src/components/ExpandableWrapper.test.tsx +5 -2
- package/src/components/ExpandableWrapper.tsx +8 -6
- package/src/components/FeedbackInline.test.tsx +6 -3
- package/src/components/FeedbackInline.tsx +8 -6
- package/src/components/PresentationFeedback.test.tsx +163 -0
- package/src/components/PresentationFeedback.tsx +326 -0
- package/src/components/StreamingUIRenderer.parity.test.tsx +158 -0
- package/src/components/StreamingUIRenderer.tsx +42 -166
- package/src/components/UIResourceRenderer.identity.test.tsx +161 -0
- package/src/components/UIResourceRenderer.tsx +63 -2
- package/src/components/index.ts +10 -0
- package/src/context/MCPUIStringsContext.test.tsx +116 -0
- package/src/context/MCPUIStringsContext.tsx +128 -0
- package/src/index.ts +35 -0
- package/src/utils/duplicate-mount-registry.test.ts +82 -0
- package/src/utils/duplicate-mount-registry.ts +113 -0
- package/src/utils/stable-key.test.ts +96 -0
- package/src/utils/stable-key.ts +91 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/vite.config.ts +1 -0
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* StreamingUIRenderer Component
|
|
2
|
+
* StreamingUIRenderer Component
|
|
3
3
|
*
|
|
4
|
-
* Renders streaming dashboard components with skeleton states and progress
|
|
5
|
-
* Uses the useStreamingUI hook for SSE connection and state
|
|
4
|
+
* Renders streaming dashboard components with skeleton states and progress
|
|
5
|
+
* indicators. Uses the `useStreamingUI` hook for SSE connection and state.
|
|
6
|
+
*
|
|
7
|
+
* ## Rendering parity (v6.6.0 — closes Gap 1 of ROADMAP-opendata-macro-mcpui)
|
|
8
|
+
*
|
|
9
|
+
* Each component received over SSE is delegated to the real
|
|
10
|
+
* `<UIResourceRenderer>`. Streamed `table` / `chart` / `map` / `action-group`
|
|
11
|
+
* therefore render with the SAME fidelity as a static layout — no more
|
|
12
|
+
* simplified "type + title" placeholder. Validation, telemetry, the error
|
|
13
|
+
* boundary and `errorMode` all come from `<UIResourceRenderer>`, so the two
|
|
14
|
+
* paths cannot drift.
|
|
15
|
+
*
|
|
16
|
+
* Delegation is a one-way value import (`UIResourceRenderer` never imports
|
|
17
|
+
* this file — no cycle). The streamed component's `position` is normalized
|
|
18
|
+
* to full-width before delegation : this component owns the 12-column grid,
|
|
19
|
+
* `<UIResourceRenderer>` only owns the component's own rendering.
|
|
6
20
|
*
|
|
7
21
|
* Features:
|
|
8
22
|
* - Skeleton loading states while components stream
|
|
@@ -21,14 +35,11 @@
|
|
|
21
35
|
* ```
|
|
22
36
|
*/
|
|
23
37
|
|
|
24
|
-
import { Show, For, createSignal, onMount
|
|
38
|
+
import { Show, For, createSignal, onMount } from 'solid-js'
|
|
25
39
|
import { useStreamingUI, type UseStreamingUIOptions } from '../hooks/useStreamingUI'
|
|
26
40
|
import type { UIComponent, RendererError } from '../types'
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
29
|
-
import { markRenderStart, markRenderEnd, PERF_PREFIX } from '../utils/perf'
|
|
30
|
-
import { useTelemetry } from '../context/MCPUITelemetryContext'
|
|
31
|
-
import type { ValidationErrorMode } from './UIResourceRenderer'
|
|
41
|
+
import { UIResourceRenderer, type ValidationErrorMode } from './UIResourceRenderer'
|
|
42
|
+
import { useMCPUIStrings } from '../context/MCPUIStringsContext'
|
|
32
43
|
|
|
33
44
|
export interface StreamingUIRendererProps extends UseStreamingUIOptions {
|
|
34
45
|
class?: string
|
|
@@ -38,164 +49,27 @@ export interface StreamingUIRendererProps extends UseStreamingUIOptions {
|
|
|
38
49
|
/**
|
|
39
50
|
* How to react when a streamed component fails `validateComponent()`
|
|
40
51
|
* (v5.4.0). Defaults to `'block'` (full red error card — pre-v5.4.0
|
|
41
|
-
* behavior).
|
|
52
|
+
* behavior). Forwarded to the delegated `<UIResourceRenderer>`.
|
|
42
53
|
*/
|
|
43
54
|
errorMode?: ValidationErrorMode
|
|
55
|
+
/**
|
|
56
|
+
* Visibility behavior of the inline expand button on streamed components
|
|
57
|
+
* wrapped in `<ExpandableWrapper>` (v6.6.0 — parity with the static
|
|
58
|
+
* `<UIResourceRenderer toolbarVariant>` prop). Forwarded as-is.
|
|
59
|
+
*/
|
|
60
|
+
toolbarVariant?: 'hover' | 'always-visible'
|
|
44
61
|
}
|
|
45
62
|
|
|
46
63
|
/**
|
|
47
|
-
*
|
|
48
|
-
* (
|
|
64
|
+
* The 12-column placement of a streamed component is owned by this
|
|
65
|
+
* component's outer grid (the cell `<div>` below). Delegating the component
|
|
66
|
+
* verbatim to `<UIResourceRenderer>` would re-apply that placement inside a
|
|
67
|
+
* fresh nested 12-column grid and visually misplace it. We hand
|
|
68
|
+
* `<UIResourceRenderer>` a full-width copy so it only renders the component,
|
|
69
|
+
* not a competing layout.
|
|
49
70
|
*/
|
|
50
|
-
function
|
|
51
|
-
component:
|
|
52
|
-
onError?: (error: RendererError) => void
|
|
53
|
-
errorMode?: ValidationErrorMode
|
|
54
|
-
}) {
|
|
55
|
-
// Performance marks (v5.4.0) — see utils/perf.ts
|
|
56
|
-
markRenderStart(props.component.id)
|
|
57
|
-
|
|
58
|
-
// Telemetry sink (B.5 — v5.6.0). Same wiring as ComponentRenderer in
|
|
59
|
-
// UIResourceRenderer.tsx — null when no Provider, no-op everywhere then.
|
|
60
|
-
const telemetry = useTelemetry()
|
|
61
|
-
|
|
62
|
-
onMount(() => {
|
|
63
|
-
markRenderEnd(props.component.id)
|
|
64
|
-
if (telemetry) {
|
|
65
|
-
const ts = Date.now()
|
|
66
|
-
telemetry.dispatch({
|
|
67
|
-
type: 'component:mounted',
|
|
68
|
-
id: props.component.id,
|
|
69
|
-
componentType: props.component.type,
|
|
70
|
-
ts,
|
|
71
|
-
})
|
|
72
|
-
if (typeof performance !== 'undefined' && typeof performance.getEntriesByName === 'function') {
|
|
73
|
-
const entries = performance.getEntriesByName(`${PERF_PREFIX}${props.component.id}:render`, 'measure')
|
|
74
|
-
const last = entries[entries.length - 1]
|
|
75
|
-
if (last) {
|
|
76
|
-
telemetry.dispatch({
|
|
77
|
-
type: 'component:rendered',
|
|
78
|
-
id: props.component.id,
|
|
79
|
-
componentType: props.component.type,
|
|
80
|
-
durationMs: last.duration,
|
|
81
|
-
ts,
|
|
82
|
-
})
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
})
|
|
87
|
-
|
|
88
|
-
onCleanup(() => {
|
|
89
|
-
if (telemetry) {
|
|
90
|
-
telemetry.dispatch({
|
|
91
|
-
type: 'component:unmounted',
|
|
92
|
-
id: props.component.id,
|
|
93
|
-
componentType: props.component.type,
|
|
94
|
-
ts: Date.now(),
|
|
95
|
-
})
|
|
96
|
-
}
|
|
97
|
-
})
|
|
98
|
-
|
|
99
|
-
// Validate component before rendering
|
|
100
|
-
const validation = validateComponent(props.component)
|
|
101
|
-
if (!validation.valid) {
|
|
102
|
-
props.onError?.({
|
|
103
|
-
type: 'validation',
|
|
104
|
-
message: 'Component validation failed',
|
|
105
|
-
componentId: props.component.id,
|
|
106
|
-
details: validation.errors,
|
|
107
|
-
})
|
|
108
|
-
|
|
109
|
-
if (telemetry) {
|
|
110
|
-
telemetry.dispatch({
|
|
111
|
-
type: 'validation:failed',
|
|
112
|
-
id: props.component.id,
|
|
113
|
-
componentType: props.component.type,
|
|
114
|
-
errorCount: validation.errors?.length ?? 0,
|
|
115
|
-
firstErrorCode: validation.errors?.[0]?.code ?? null,
|
|
116
|
-
ts: Date.now(),
|
|
117
|
-
})
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
const mode: ValidationErrorMode = props.errorMode ?? 'block'
|
|
121
|
-
const firstError = validation.errors?.[0]?.message || 'Unknown validation error'
|
|
122
|
-
|
|
123
|
-
if (mode === 'silent') {
|
|
124
|
-
return null
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
if (mode === 'inline-warn') {
|
|
128
|
-
return (
|
|
129
|
-
<div
|
|
130
|
-
class="inline-flex items-center gap-1.5 px-2 py-1 rounded-md bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 text-xs text-yellow-800 dark:text-yellow-200"
|
|
131
|
-
role="alert"
|
|
132
|
-
aria-label="Component validation warning"
|
|
133
|
-
title={firstError}
|
|
134
|
-
>
|
|
135
|
-
<svg
|
|
136
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
137
|
-
class="w-3.5 h-3.5"
|
|
138
|
-
viewBox="0 0 24 24"
|
|
139
|
-
fill="none"
|
|
140
|
-
stroke="currentColor"
|
|
141
|
-
stroke-width="2"
|
|
142
|
-
stroke-linecap="round"
|
|
143
|
-
stroke-linejoin="round"
|
|
144
|
-
aria-hidden="true"
|
|
145
|
-
>
|
|
146
|
-
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
|
147
|
-
<line x1="12" y1="9" x2="12" y2="13" />
|
|
148
|
-
<line x1="12" y1="17" x2="12.01" y2="17" />
|
|
149
|
-
</svg>
|
|
150
|
-
<span>Invalid {props.component.type}</span>
|
|
151
|
-
</div>
|
|
152
|
-
)
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
return (
|
|
156
|
-
<div class="w-full bg-error-subtle border border-border-error rounded-lg p-4">
|
|
157
|
-
<p class="text-sm font-medium text-error-primary">Validation Error</p>
|
|
158
|
-
<p class="text-xs text-text-secondary mt-1">
|
|
159
|
-
{firstError}
|
|
160
|
-
</p>
|
|
161
|
-
</div>
|
|
162
|
-
)
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
// Simplified renderer - just show component type and title
|
|
166
|
-
// Full rendering logic in UIResourceRenderer
|
|
167
|
-
const params = props.component.params as any
|
|
168
|
-
|
|
169
|
-
return (
|
|
170
|
-
<GenerativeUIErrorBoundary
|
|
171
|
-
componentId={props.component.id}
|
|
172
|
-
componentType={props.component.type}
|
|
173
|
-
onError={props.onError}
|
|
174
|
-
allowRetry={false}
|
|
175
|
-
>
|
|
176
|
-
<div class="w-full bg-surface-secondary border border-border-subtle rounded-lg p-4">
|
|
177
|
-
<div class="flex items-center gap-2 mb-2">
|
|
178
|
-
<span class="text-xs font-medium text-text-tertiary uppercase">
|
|
179
|
-
{props.component.type}
|
|
180
|
-
</span>
|
|
181
|
-
</div>
|
|
182
|
-
<Show when={params?.title}>
|
|
183
|
-
<h3 class="text-sm font-semibold text-text-primary">{params.title}</h3>
|
|
184
|
-
</Show>
|
|
185
|
-
<Show when={props.component.type === 'metric' && params?.value}>
|
|
186
|
-
<div class="mt-2">
|
|
187
|
-
<p class="text-2xl font-semibold text-text-primary">{params.value}</p>
|
|
188
|
-
<Show when={params.unit}>
|
|
189
|
-
<span class="text-sm text-text-secondary">{params.unit}</span>
|
|
190
|
-
</Show>
|
|
191
|
-
</div>
|
|
192
|
-
</Show>
|
|
193
|
-
<div class="mt-3 text-xs text-text-tertiary">
|
|
194
|
-
Component ID: {props.component.id.slice(0, 8)}...
|
|
195
|
-
</div>
|
|
196
|
-
</div>
|
|
197
|
-
</GenerativeUIErrorBoundary>
|
|
198
|
-
)
|
|
71
|
+
function asFullWidth(component: UIComponent): UIComponent {
|
|
72
|
+
return { ...component, position: { colStart: 1, colSpan: 12 } }
|
|
199
73
|
}
|
|
200
74
|
|
|
201
75
|
export function StreamingUIRenderer(props: StreamingUIRendererProps) {
|
|
@@ -210,6 +84,7 @@ export function StreamingUIRenderer(props: StreamingUIRendererProps) {
|
|
|
210
84
|
onComponentReceived: props.onComponentReceived,
|
|
211
85
|
})
|
|
212
86
|
|
|
87
|
+
const strings = useMCPUIStrings()
|
|
213
88
|
const [animatingComponents, setAnimatingComponents] = createSignal<Set<string>>(new Set())
|
|
214
89
|
|
|
215
90
|
// Track new components for animation
|
|
@@ -292,7 +167,7 @@ export function StreamingUIRenderer(props: StreamingUIRendererProps) {
|
|
|
292
167
|
class="mt-3 rounded-md bg-error-primary px-3 py-1.5 text-sm font-medium text-white hover:bg-error-hover"
|
|
293
168
|
onClick={() => startStreaming()}
|
|
294
169
|
>
|
|
295
|
-
|
|
170
|
+
{strings.retry}
|
|
296
171
|
</button>
|
|
297
172
|
</Show>
|
|
298
173
|
</div>
|
|
@@ -300,7 +175,7 @@ export function StreamingUIRenderer(props: StreamingUIRendererProps) {
|
|
|
300
175
|
|
|
301
176
|
{/* Components Grid */}
|
|
302
177
|
<div class="grid grid-cols-12 gap-4">
|
|
303
|
-
{/* Render received components */}
|
|
178
|
+
{/* Render received components — delegated to the real UIResourceRenderer */}
|
|
304
179
|
<For each={components()}>
|
|
305
180
|
{(component) => {
|
|
306
181
|
// Trigger animation on mount (SSR-safe, no 'use' directive needed)
|
|
@@ -314,10 +189,11 @@ export function StreamingUIRenderer(props: StreamingUIRendererProps) {
|
|
|
314
189
|
`}
|
|
315
190
|
style={`grid-column-start: ${component.position.colStart}; grid-column-end: ${component.position.colStart + component.position.colSpan}`}
|
|
316
191
|
>
|
|
317
|
-
<
|
|
318
|
-
|
|
319
|
-
onError={props.onRenderError}
|
|
192
|
+
<UIResourceRenderer
|
|
193
|
+
content={asFullWidth(component)}
|
|
320
194
|
errorMode={props.errorMode}
|
|
195
|
+
onError={props.onRenderError}
|
|
196
|
+
toolbarVariant={props.toolbarVariant}
|
|
321
197
|
/>
|
|
322
198
|
</div>
|
|
323
199
|
)
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v6.5.0 — Identity stability + opt-in observability for <UIResourceRenderer>.
|
|
3
|
+
*
|
|
4
|
+
* Coverage targets :
|
|
5
|
+
* 1. Layout content gets `data-mcp-ui-layout-id` from layout.id
|
|
6
|
+
* 2. Layout content without id falls back to a content hash
|
|
7
|
+
* 3. Single-component content gets `data-mcp-ui-component-id` (no layout id)
|
|
8
|
+
* 4. Each rendered child carries `data-mcp-ui-component-id`
|
|
9
|
+
* 5. `onMountDuplicate` callback fires on the 2nd concurrent mount
|
|
10
|
+
* 6. Module-level reporter (`setDuplicateMountReporter`) fires on duplicate
|
|
11
|
+
* 7. Single mount fires no duplicate notification
|
|
12
|
+
* 8. Cleanup on unmount allows the same key to be re-mounted without warn
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
16
|
+
import { render, cleanup } from '@solidjs/testing-library'
|
|
17
|
+
import { UIResourceRenderer } from './UIResourceRenderer'
|
|
18
|
+
import {
|
|
19
|
+
setDuplicateMountReporter,
|
|
20
|
+
_resetRegistry,
|
|
21
|
+
_getMountCount,
|
|
22
|
+
} from '../utils/duplicate-mount-registry'
|
|
23
|
+
import { getUiResourceStableKey } from '../utils/stable-key'
|
|
24
|
+
|
|
25
|
+
const SIMPLE_TEXT_COMPONENT = {
|
|
26
|
+
id: 'text-comp-1',
|
|
27
|
+
type: 'text' as const,
|
|
28
|
+
position: { colStart: 1, colSpan: 12 },
|
|
29
|
+
params: { content: 'Hello' },
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const SIMPLE_LAYOUT = {
|
|
33
|
+
id: 'layout-1',
|
|
34
|
+
components: [SIMPLE_TEXT_COMPONENT],
|
|
35
|
+
grid: { columns: 12, gap: '1rem' },
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('UIResourceRenderer identity (v6.5.0)', () => {
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
cleanup()
|
|
41
|
+
_resetRegistry()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('layout content emits data-mcp-ui-layout-id from layout.id', () => {
|
|
45
|
+
const { container } = render(() => <UIResourceRenderer content={SIMPLE_LAYOUT} />)
|
|
46
|
+
const wrapper = container.querySelector('[data-mcp-ui-layout-id="layout-1"]')
|
|
47
|
+
expect(wrapper).toBeTruthy()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('layout without id falls back to a content hash', () => {
|
|
51
|
+
const bareLayout = {
|
|
52
|
+
components: [SIMPLE_TEXT_COMPONENT],
|
|
53
|
+
grid: { columns: 12, gap: '1rem' },
|
|
54
|
+
} as any
|
|
55
|
+
const expectedKey = getUiResourceStableKey(bareLayout)
|
|
56
|
+
const { container } = render(() => <UIResourceRenderer content={bareLayout} />)
|
|
57
|
+
const wrapper = container.querySelector(`[data-mcp-ui-layout-id="${expectedKey}"]`)
|
|
58
|
+
expect(wrapper).toBeTruthy()
|
|
59
|
+
// Hash form (FNV-1a base36) is 7 chars
|
|
60
|
+
expect(expectedKey).toMatch(/^[a-z0-9]{7}$/)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('single-component content emits data-mcp-ui-component-id (no layout id)', () => {
|
|
64
|
+
const { container } = render(() => <UIResourceRenderer content={SIMPLE_TEXT_COMPONENT} />)
|
|
65
|
+
expect(container.querySelector('[data-mcp-ui-layout-id]')).toBeNull()
|
|
66
|
+
const wrappers = container.querySelectorAll('[data-mcp-ui-component-id="text-comp-1"]')
|
|
67
|
+
// Outer wrapper + inner per-component wrapper both carry the id
|
|
68
|
+
expect(wrappers.length).toBeGreaterThanOrEqual(1)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('each child component wrapper inside a layout carries data-mcp-ui-component-id', () => {
|
|
72
|
+
const layout = {
|
|
73
|
+
id: 'multi',
|
|
74
|
+
components: [
|
|
75
|
+
{ id: 'comp-a', type: 'text', position: { colStart: 1, colSpan: 6 }, params: { content: 'A' } },
|
|
76
|
+
{ id: 'comp-b', type: 'text', position: { colStart: 7, colSpan: 6 }, params: { content: 'B' } },
|
|
77
|
+
],
|
|
78
|
+
grid: { columns: 12, gap: '1rem' },
|
|
79
|
+
} as any
|
|
80
|
+
const { container } = render(() => <UIResourceRenderer content={layout} />)
|
|
81
|
+
expect(container.querySelector('[data-mcp-ui-component-id="comp-a"]')).toBeTruthy()
|
|
82
|
+
expect(container.querySelector('[data-mcp-ui-component-id="comp-b"]')).toBeTruthy()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('fires onMountDuplicate on the 2nd concurrent mount of the same key', () => {
|
|
86
|
+
const onDup = vi.fn()
|
|
87
|
+
render(() => (
|
|
88
|
+
<>
|
|
89
|
+
<UIResourceRenderer content={SIMPLE_LAYOUT} onMountDuplicate={onDup} />
|
|
90
|
+
<UIResourceRenderer content={SIMPLE_LAYOUT} onMountDuplicate={onDup} />
|
|
91
|
+
</>
|
|
92
|
+
))
|
|
93
|
+
// Only the 2nd mount triggers the callback (count crosses 2)
|
|
94
|
+
expect(onDup).toHaveBeenCalledTimes(1)
|
|
95
|
+
expect(onDup).toHaveBeenCalledWith(
|
|
96
|
+
expect.objectContaining({ key: 'layout-1', count: 2 })
|
|
97
|
+
)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('does NOT fire onMountDuplicate on a single mount', () => {
|
|
101
|
+
const onDup = vi.fn()
|
|
102
|
+
render(() => <UIResourceRenderer content={SIMPLE_LAYOUT} onMountDuplicate={onDup} />)
|
|
103
|
+
expect(onDup).not.toHaveBeenCalled()
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('module-level setDuplicateMountReporter fires on the 2nd mount', () => {
|
|
107
|
+
const reporter = vi.fn()
|
|
108
|
+
setDuplicateMountReporter(reporter)
|
|
109
|
+
render(() => (
|
|
110
|
+
<>
|
|
111
|
+
<UIResourceRenderer content={SIMPLE_LAYOUT} />
|
|
112
|
+
<UIResourceRenderer content={SIMPLE_LAYOUT} />
|
|
113
|
+
</>
|
|
114
|
+
))
|
|
115
|
+
expect(reporter).toHaveBeenCalledTimes(1)
|
|
116
|
+
expect(reporter).toHaveBeenCalledWith(
|
|
117
|
+
expect.objectContaining({ key: 'layout-1', count: 2 })
|
|
118
|
+
)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('mounts unique-key payloads independently (no false positives)', () => {
|
|
122
|
+
const onDup = vi.fn()
|
|
123
|
+
const reporter = vi.fn()
|
|
124
|
+
setDuplicateMountReporter(reporter)
|
|
125
|
+
const a = { ...SIMPLE_LAYOUT, id: 'layout-A' }
|
|
126
|
+
const b = { ...SIMPLE_LAYOUT, id: 'layout-B' }
|
|
127
|
+
render(() => (
|
|
128
|
+
<>
|
|
129
|
+
<UIResourceRenderer content={a} onMountDuplicate={onDup} />
|
|
130
|
+
<UIResourceRenderer content={b} onMountDuplicate={onDup} />
|
|
131
|
+
</>
|
|
132
|
+
))
|
|
133
|
+
expect(onDup).not.toHaveBeenCalled()
|
|
134
|
+
expect(reporter).not.toHaveBeenCalled()
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('cleanup unregisters the mount so the registry never leaks', () => {
|
|
138
|
+
const { unmount } = render(() => <UIResourceRenderer content={SIMPLE_LAYOUT} />)
|
|
139
|
+
expect(_getMountCount('layout-1')).toBe(1)
|
|
140
|
+
unmount()
|
|
141
|
+
expect(_getMountCount('layout-1')).toBe(0)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('debugDuplicateMounts prop forces a console.warn even when global debug off', () => {
|
|
145
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
146
|
+
try {
|
|
147
|
+
render(() => (
|
|
148
|
+
<>
|
|
149
|
+
<UIResourceRenderer content={SIMPLE_LAYOUT} debugDuplicateMounts />
|
|
150
|
+
<UIResourceRenderer content={SIMPLE_LAYOUT} debugDuplicateMounts />
|
|
151
|
+
</>
|
|
152
|
+
))
|
|
153
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
154
|
+
'[mcp-ui] duplicate UIResourceRenderer mount',
|
|
155
|
+
expect.objectContaining({ key: 'layout-1', count: 2 })
|
|
156
|
+
)
|
|
157
|
+
} finally {
|
|
158
|
+
warnSpy.mockRestore()
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
})
|
|
@@ -10,6 +10,14 @@ import type { UIComponent, UILayout, RendererError, TableVirtualizeOptions } fro
|
|
|
10
10
|
import { validateComponent, DEFAULT_RESOURCE_LIMITS, getIframeSandbox } from '../services/validation'
|
|
11
11
|
import { GenerativeUIErrorBoundary } from './GenerativeUIErrorBoundary'
|
|
12
12
|
import { markRenderStart, markRenderEnd, PERF_PREFIX } from '../utils/perf'
|
|
13
|
+
import { isDebugEnabled } from '../utils/logger'
|
|
14
|
+
import { getUiResourceStableKey } from '../utils/stable-key'
|
|
15
|
+
import {
|
|
16
|
+
_registerMount,
|
|
17
|
+
_unregisterMount,
|
|
18
|
+
getDuplicateMountReporter,
|
|
19
|
+
type DuplicateMountInfo,
|
|
20
|
+
} from '../utils/duplicate-mount-registry'
|
|
13
21
|
import { useTelemetry } from '../context/MCPUITelemetryContext'
|
|
14
22
|
|
|
15
23
|
/**
|
|
@@ -135,6 +143,27 @@ export interface UIResourceRendererProps {
|
|
|
135
143
|
* graph, map, video, carousel, image-gallery, code.
|
|
136
144
|
*/
|
|
137
145
|
toolbarVariant?: 'hover' | 'always-visible'
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Per-instance hook fired when this renderer mounts a content key that
|
|
149
|
+
* is already mounted elsewhere in the document (v6.5.0 — closes Demande 2
|
|
150
|
+
* of `BRIEF-MCPUI-2026-05-10.md`).
|
|
151
|
+
*
|
|
152
|
+
* The key comes from `getUiResourceStableKey(content)` — `content.id` if
|
|
153
|
+
* provided, else a content hash. The reporter fires every time the
|
|
154
|
+
* concurrent mount count crosses 2+ ; consumers decide what to do
|
|
155
|
+
* (`console.warn`, telemetry beacon, debug overlay, …). The renderer
|
|
156
|
+
* never deduplicates visually on its own.
|
|
157
|
+
*/
|
|
158
|
+
onMountDuplicate?: (info: DuplicateMountInfo) => void
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* When `true`, log duplicate mounts to `console.warn` from this instance
|
|
162
|
+
* even when the global `isDebugEnabled()` flag is off. Use to opt-in to
|
|
163
|
+
* console noise on a single suspect surface without flipping the global
|
|
164
|
+
* debug switch (v6.5.0).
|
|
165
|
+
*/
|
|
166
|
+
debugDuplicateMounts?: boolean
|
|
138
167
|
}
|
|
139
168
|
|
|
140
169
|
/**
|
|
@@ -1766,6 +1795,30 @@ export const UIResourceRenderer: Component<UIResourceRendererProps> = (props) =>
|
|
|
1766
1795
|
|
|
1767
1796
|
const layoutData = layout()
|
|
1768
1797
|
|
|
1798
|
+
// ── Identity + duplicate-mount detection (v6.5.0) ─────────────
|
|
1799
|
+
// `isLayoutContent` distinguishes a real composite/layout payload from
|
|
1800
|
+
// the synthetic single-component wrapping above. Drives whether the
|
|
1801
|
+
// outer wrapper carries `data-mcp-ui-layout-id` or `data-mcp-ui-component-id`.
|
|
1802
|
+
const isLayoutContent =
|
|
1803
|
+
!('type' in props.content) || (props.content as { type?: string }).type === 'composite'
|
|
1804
|
+
const outerKey = createMemo(() => getUiResourceStableKey(props.content))
|
|
1805
|
+
|
|
1806
|
+
onMount(() => {
|
|
1807
|
+
const key = outerKey()
|
|
1808
|
+
const info = _registerMount(key)
|
|
1809
|
+
if (info.count > 1) {
|
|
1810
|
+
props.onMountDuplicate?.(info)
|
|
1811
|
+
getDuplicateMountReporter()?.(info)
|
|
1812
|
+
if (isDebugEnabled() || props.debugDuplicateMounts) {
|
|
1813
|
+
// eslint-disable-next-line no-console
|
|
1814
|
+
console.warn('[mcp-ui] duplicate UIResourceRenderer mount', info)
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
})
|
|
1818
|
+
onCleanup(() => {
|
|
1819
|
+
_unregisterMount(outerKey())
|
|
1820
|
+
})
|
|
1821
|
+
|
|
1769
1822
|
// Wrapper function for RenderContext (breaks circular dependency)
|
|
1770
1823
|
const renderComponent = (component: UIComponent, onError?: (error: RendererError) => void) => (
|
|
1771
1824
|
<ComponentRenderer component={component} onError={onError} errorMode={props.errorMode} toolbarVariant={props.toolbarVariant} />
|
|
@@ -1773,11 +1826,19 @@ export const UIResourceRenderer: Component<UIResourceRendererProps> = (props) =>
|
|
|
1773
1826
|
|
|
1774
1827
|
return (
|
|
1775
1828
|
<RenderProvider renderComponent={renderComponent}>
|
|
1776
|
-
<div
|
|
1829
|
+
<div
|
|
1830
|
+
class={`w-full ${props.class || ''}`}
|
|
1831
|
+
{...(isLayoutContent
|
|
1832
|
+
? { 'data-mcp-ui-layout-id': outerKey() }
|
|
1833
|
+
: { 'data-mcp-ui-component-id': outerKey() })}
|
|
1834
|
+
>
|
|
1777
1835
|
<div class="grid gap-4" style={gridContainerStyle()}>
|
|
1778
1836
|
<For each={layoutData.components}>
|
|
1779
1837
|
{(component) => (
|
|
1780
|
-
<div
|
|
1838
|
+
<div
|
|
1839
|
+
style={getGridStyleString(component)}
|
|
1840
|
+
data-mcp-ui-component-id={getUiResourceStableKey(component)}
|
|
1841
|
+
>
|
|
1781
1842
|
<ComponentRenderer component={component} onError={props.onError} errorMode={props.errorMode} toolbarVariant={props.toolbarVariant} />
|
|
1782
1843
|
</div>
|
|
1783
1844
|
)}
|
package/src/components/index.ts
CHANGED
|
@@ -10,6 +10,16 @@ export type { UIResourceRendererProps } from './UIResourceRenderer'
|
|
|
10
10
|
export { StreamingUIRenderer } from './StreamingUIRenderer'
|
|
11
11
|
export type { StreamingUIRendererProps } from './StreamingUIRenderer'
|
|
12
12
|
|
|
13
|
+
// Presentation feedback (v6.6.0 — R3, distinct from FeedbackInline)
|
|
14
|
+
export {
|
|
15
|
+
PresentationFeedback,
|
|
16
|
+
DEFAULT_PRESENTATION_FEEDBACK_LABELS,
|
|
17
|
+
} from './PresentationFeedback'
|
|
18
|
+
export type {
|
|
19
|
+
PresentationFeedbackProps,
|
|
20
|
+
PresentationFeedbackLabels,
|
|
21
|
+
} from './PresentationFeedback'
|
|
22
|
+
|
|
13
23
|
export { GenerativeUIErrorBoundary } from './GenerativeUIErrorBoundary'
|
|
14
24
|
export type { GenerativeUIErrorBoundaryProps } from './GenerativeUIErrorBoundary'
|
|
15
25
|
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v6.6.0 — MCPUIStringsProvider (D2 / R4 of ROADMAP-opendata-macro-mcpui).
|
|
3
|
+
*
|
|
4
|
+
* Coverage:
|
|
5
|
+
* 1. Defaults are English, available with no provider mounted
|
|
6
|
+
* 2. Provider does a partial merge over the EN defaults
|
|
7
|
+
* 3. FeedbackInline reads chrome strings from the provider
|
|
8
|
+
* 4. FeedbackInline `positiveAck` / `negativeAck` props still win over the provider
|
|
9
|
+
* 5. ExpandableWrapper reads the expand-button tooltip from the provider
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, it, expect, beforeEach } from 'vitest'
|
|
13
|
+
import { render, cleanup, fireEvent } from '@solidjs/testing-library'
|
|
14
|
+
import {
|
|
15
|
+
MCPUIStringsProvider,
|
|
16
|
+
useMCPUIStrings,
|
|
17
|
+
DEFAULT_MCPUI_STRINGS,
|
|
18
|
+
} from './MCPUIStringsContext'
|
|
19
|
+
import { FeedbackInline } from '../components/FeedbackInline'
|
|
20
|
+
import { ExpandableWrapper } from '../components/ExpandableWrapper'
|
|
21
|
+
|
|
22
|
+
describe('MCPUIStringsContext (v6.6.0)', () => {
|
|
23
|
+
beforeEach(() => cleanup())
|
|
24
|
+
|
|
25
|
+
it('defaults are English', () => {
|
|
26
|
+
expect(DEFAULT_MCPUI_STRINGS.expand).toBe('Expand')
|
|
27
|
+
expect(DEFAULT_MCPUI_STRINGS.feedbackUseful).toBe('Useful')
|
|
28
|
+
expect(DEFAULT_MCPUI_STRINGS.feedbackPositiveAck).toBe('Thanks!')
|
|
29
|
+
expect(DEFAULT_MCPUI_STRINGS.retry).toBe('Retry')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('useMCPUIStrings returns the EN defaults with no provider mounted', () => {
|
|
33
|
+
let captured: ReturnType<typeof useMCPUIStrings> | undefined
|
|
34
|
+
const Probe = () => {
|
|
35
|
+
captured = useMCPUIStrings()
|
|
36
|
+
return <span>probe</span>
|
|
37
|
+
}
|
|
38
|
+
render(() => <Probe />)
|
|
39
|
+
expect(captured).toEqual(DEFAULT_MCPUI_STRINGS)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('provider partial-merges over the EN defaults', () => {
|
|
43
|
+
let captured: ReturnType<typeof useMCPUIStrings> | undefined
|
|
44
|
+
const Probe = () => {
|
|
45
|
+
captured = useMCPUIStrings()
|
|
46
|
+
return <span>probe</span>
|
|
47
|
+
}
|
|
48
|
+
render(() => (
|
|
49
|
+
<MCPUIStringsProvider strings={{ expand: 'Agrandir', feedbackUseful: 'Utile' }}>
|
|
50
|
+
<Probe />
|
|
51
|
+
</MCPUIStringsProvider>
|
|
52
|
+
))
|
|
53
|
+
// Overridden keys
|
|
54
|
+
expect(captured!.expand).toBe('Agrandir')
|
|
55
|
+
expect(captured!.feedbackUseful).toBe('Utile')
|
|
56
|
+
// Untouched keys fall back to EN
|
|
57
|
+
expect(captured!.retry).toBe('Retry')
|
|
58
|
+
expect(captured!.closeExpandedView).toBe('Close expanded view')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('FeedbackInline reads its ack from the provider (FR override)', () => {
|
|
62
|
+
const { getByText, container } = render(() => (
|
|
63
|
+
<MCPUIStringsProvider
|
|
64
|
+
strings={{ feedbackPositiveAck: 'Merci !', feedbackUseful: 'Utile' }}
|
|
65
|
+
>
|
|
66
|
+
<FeedbackInline onSubmit={() => {}} />
|
|
67
|
+
</MCPUIStringsProvider>
|
|
68
|
+
))
|
|
69
|
+
// Tooltip from provider
|
|
70
|
+
const upBtn = container.querySelector('[data-feedback-inline-rating="positive"]')
|
|
71
|
+
expect(upBtn?.getAttribute('title')).toBe('Utile')
|
|
72
|
+
// Ack from provider after click
|
|
73
|
+
fireEvent.click(upBtn!)
|
|
74
|
+
expect(getByText('Merci !')).toBeTruthy()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('FeedbackInline defaults to EN ack when no provider is mounted', () => {
|
|
78
|
+
const { getByText, container } = render(() => <FeedbackInline onSubmit={() => {}} />)
|
|
79
|
+
const upBtn = container.querySelector('[data-feedback-inline-rating="positive"]')
|
|
80
|
+
fireEvent.click(upBtn!)
|
|
81
|
+
expect(getByText('Thanks!')).toBeTruthy()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('FeedbackInline positiveAck prop still wins over the provider', () => {
|
|
85
|
+
const { getByText, container } = render(() => (
|
|
86
|
+
<MCPUIStringsProvider strings={{ feedbackPositiveAck: 'FromProvider' }}>
|
|
87
|
+
<FeedbackInline onSubmit={() => {}} positiveAck="FromProp" />
|
|
88
|
+
</MCPUIStringsProvider>
|
|
89
|
+
))
|
|
90
|
+
const upBtn = container.querySelector('[data-feedback-inline-rating="positive"]')
|
|
91
|
+
fireEvent.click(upBtn!)
|
|
92
|
+
expect(getByText('FromProp')).toBeTruthy()
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('ExpandableWrapper reads the expand-button tooltip from the provider', () => {
|
|
96
|
+
const { container } = render(() => (
|
|
97
|
+
<MCPUIStringsProvider strings={{ expand: 'Plein écran' }}>
|
|
98
|
+
<ExpandableWrapper title="Données">
|
|
99
|
+
<div>content</div>
|
|
100
|
+
</ExpandableWrapper>
|
|
101
|
+
</MCPUIStringsProvider>
|
|
102
|
+
))
|
|
103
|
+
const expandBtn = container.querySelector('button[aria-label="Expand to fullscreen"]')
|
|
104
|
+
expect(expandBtn?.getAttribute('title')).toBe('Plein écran')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('ExpandableWrapper falls back to EN with no provider', () => {
|
|
108
|
+
const { container } = render(() => (
|
|
109
|
+
<ExpandableWrapper title="Data">
|
|
110
|
+
<div>content</div>
|
|
111
|
+
</ExpandableWrapper>
|
|
112
|
+
))
|
|
113
|
+
const expandBtn = container.querySelector('button[aria-label="Expand to fullscreen"]')
|
|
114
|
+
expect(expandBtn?.getAttribute('title')).toBe('Expand')
|
|
115
|
+
})
|
|
116
|
+
})
|