@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.
Files changed (107) hide show
  1. package/CHANGELOG.md +230 -0
  2. package/README.md +37 -0
  3. package/dist/adapters/connector.cjs +112 -0
  4. package/dist/adapters/connector.cjs.map +1 -0
  5. package/dist/adapters/connector.d.ts +71 -0
  6. package/dist/adapters/connector.d.ts.map +1 -0
  7. package/dist/adapters/connector.js +112 -0
  8. package/dist/adapters/connector.js.map +1 -0
  9. package/dist/adapters/index.d.ts +18 -0
  10. package/dist/adapters/index.d.ts.map +1 -0
  11. package/dist/adapters.cjs +6 -0
  12. package/dist/adapters.cjs.map +1 -0
  13. package/dist/adapters.d.cts +18 -0
  14. package/dist/adapters.d.ts +18 -0
  15. package/dist/adapters.js +6 -0
  16. package/dist/adapters.js.map +1 -0
  17. package/dist/components/ExpandableWrapper.cjs +24 -6
  18. package/dist/components/ExpandableWrapper.cjs.map +1 -1
  19. package/dist/components/ExpandableWrapper.d.ts.map +1 -1
  20. package/dist/components/ExpandableWrapper.js +24 -6
  21. package/dist/components/ExpandableWrapper.js.map +1 -1
  22. package/dist/components/FeedbackInline.cjs +6 -2
  23. package/dist/components/FeedbackInline.cjs.map +1 -1
  24. package/dist/components/FeedbackInline.d.ts +2 -2
  25. package/dist/components/FeedbackInline.d.ts.map +1 -1
  26. package/dist/components/FeedbackInline.js +7 -3
  27. package/dist/components/FeedbackInline.js.map +1 -1
  28. package/dist/components/PresentationFeedback.cjs +207 -0
  29. package/dist/components/PresentationFeedback.cjs.map +1 -0
  30. package/dist/components/PresentationFeedback.d.ts +113 -0
  31. package/dist/components/PresentationFeedback.d.ts.map +1 -0
  32. package/dist/components/PresentationFeedback.js +207 -0
  33. package/dist/components/PresentationFeedback.js.map +1 -0
  34. package/dist/components/StreamingUIRenderer.cjs +82 -195
  35. package/dist/components/StreamingUIRenderer.cjs.map +1 -1
  36. package/dist/components/StreamingUIRenderer.d.ts +25 -5
  37. package/dist/components/StreamingUIRenderer.d.ts.map +1 -1
  38. package/dist/components/StreamingUIRenderer.js +84 -197
  39. package/dist/components/StreamingUIRenderer.js.map +1 -1
  40. package/dist/components/UIResourceRenderer.cjs +40 -10
  41. package/dist/components/UIResourceRenderer.cjs.map +1 -1
  42. package/dist/components/UIResourceRenderer.d.ts +20 -0
  43. package/dist/components/UIResourceRenderer.d.ts.map +1 -1
  44. package/dist/components/UIResourceRenderer.js +42 -12
  45. package/dist/components/UIResourceRenderer.js.map +1 -1
  46. package/dist/components/index.d.ts +2 -0
  47. package/dist/components/index.d.ts.map +1 -1
  48. package/dist/components.cjs +3 -0
  49. package/dist/components.cjs.map +1 -1
  50. package/dist/components.d.cts +2 -0
  51. package/dist/components.d.ts +2 -0
  52. package/dist/components.js +3 -0
  53. package/dist/components.js.map +1 -1
  54. package/dist/context/MCPUIStringsContext.cjs +38 -0
  55. package/dist/context/MCPUIStringsContext.cjs.map +1 -0
  56. package/dist/context/MCPUIStringsContext.d.ts +95 -0
  57. package/dist/context/MCPUIStringsContext.d.ts.map +1 -0
  58. package/dist/context/MCPUIStringsContext.js +38 -0
  59. package/dist/context/MCPUIStringsContext.js.map +1 -0
  60. package/dist/index.cjs +12 -0
  61. package/dist/index.cjs.map +1 -1
  62. package/dist/index.d.cts +8 -0
  63. package/dist/index.d.ts +8 -0
  64. package/dist/index.d.ts.map +1 -1
  65. package/dist/index.js +12 -0
  66. package/dist/index.js.map +1 -1
  67. package/dist/mcp-ui-spec/dist/schemas.cjs +103 -0
  68. package/dist/mcp-ui-spec/dist/schemas.cjs.map +1 -1
  69. package/dist/mcp-ui-spec/dist/schemas.js +103 -0
  70. package/dist/mcp-ui-spec/dist/schemas.js.map +1 -1
  71. package/dist/utils/duplicate-mount-registry.cjs +27 -0
  72. package/dist/utils/duplicate-mount-registry.cjs.map +1 -0
  73. package/dist/utils/duplicate-mount-registry.d.ts +84 -0
  74. package/dist/utils/duplicate-mount-registry.d.ts.map +1 -0
  75. package/dist/utils/duplicate-mount-registry.js +27 -0
  76. package/dist/utils/duplicate-mount-registry.js.map +1 -0
  77. package/dist/utils/stable-key.cjs +41 -0
  78. package/dist/utils/stable-key.cjs.map +1 -0
  79. package/dist/utils/stable-key.d.ts +33 -0
  80. package/dist/utils/stable-key.d.ts.map +1 -0
  81. package/dist/utils/stable-key.js +41 -0
  82. package/dist/utils/stable-key.js.map +1 -0
  83. package/docs/briefs/ROADMAP-opendata-macro-mcpui.md +912 -0
  84. package/package.json +17 -5
  85. package/src/adapters/connector.test.ts +165 -0
  86. package/src/adapters/connector.ts +234 -0
  87. package/src/adapters/index.ts +24 -0
  88. package/src/components/ExpandableWrapper.test.tsx +5 -2
  89. package/src/components/ExpandableWrapper.tsx +8 -6
  90. package/src/components/FeedbackInline.test.tsx +6 -3
  91. package/src/components/FeedbackInline.tsx +8 -6
  92. package/src/components/PresentationFeedback.test.tsx +163 -0
  93. package/src/components/PresentationFeedback.tsx +326 -0
  94. package/src/components/StreamingUIRenderer.parity.test.tsx +158 -0
  95. package/src/components/StreamingUIRenderer.tsx +42 -166
  96. package/src/components/UIResourceRenderer.identity.test.tsx +161 -0
  97. package/src/components/UIResourceRenderer.tsx +63 -2
  98. package/src/components/index.ts +10 -0
  99. package/src/context/MCPUIStringsContext.test.tsx +116 -0
  100. package/src/context/MCPUIStringsContext.tsx +128 -0
  101. package/src/index.ts +35 -0
  102. package/src/utils/duplicate-mount-registry.test.ts +82 -0
  103. package/src/utils/duplicate-mount-registry.ts +113 -0
  104. package/src/utils/stable-key.test.ts +96 -0
  105. package/src/utils/stable-key.ts +91 -0
  106. package/tsconfig.tsbuildinfo +1 -1
  107. package/vite.config.ts +1 -0
@@ -1,8 +1,22 @@
1
1
  /**
2
- * StreamingUIRenderer Component - Phase 2
2
+ * StreamingUIRenderer Component
3
3
  *
4
- * Renders streaming dashboard components with skeleton states and progress indicators.
5
- * Uses the useStreamingUI hook for SSE connection and state management.
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, onCleanup } from 'solid-js'
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 { validateComponent } from '../services/validation'
28
- import { GenerativeUIErrorBoundary } from './GenerativeUIErrorBoundary'
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). See `ValidationErrorMode` in `UIResourceRenderer`.
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
- * Component Renderer - Inline lightweight version
48
- * (Full implementation in UIResourceRenderer)
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 StreamingComponentRenderer(props: {
51
- component: UIComponent
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
- Retry
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
- <StreamingComponentRenderer
318
- component={component}
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 class={`w-full ${props.class || ''}`}>
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 style={getGridStyleString(component)}>
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
  )}
@@ -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
+ })