@seed-ship/mcp-ui-solid 6.5.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 (83) hide show
  1. package/CHANGELOG.md +130 -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/index.d.ts +2 -0
  41. package/dist/components/index.d.ts.map +1 -1
  42. package/dist/components.cjs +3 -0
  43. package/dist/components.cjs.map +1 -1
  44. package/dist/components.d.cts +2 -0
  45. package/dist/components.d.ts +2 -0
  46. package/dist/components.js +3 -0
  47. package/dist/components.js.map +1 -1
  48. package/dist/context/MCPUIStringsContext.cjs +38 -0
  49. package/dist/context/MCPUIStringsContext.cjs.map +1 -0
  50. package/dist/context/MCPUIStringsContext.d.ts +95 -0
  51. package/dist/context/MCPUIStringsContext.d.ts.map +1 -0
  52. package/dist/context/MCPUIStringsContext.js +38 -0
  53. package/dist/context/MCPUIStringsContext.js.map +1 -0
  54. package/dist/index.cjs +8 -0
  55. package/dist/index.cjs.map +1 -1
  56. package/dist/index.d.cts +5 -0
  57. package/dist/index.d.ts +5 -0
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/index.js +8 -0
  60. package/dist/index.js.map +1 -1
  61. package/dist/mcp-ui-spec/dist/schemas.cjs +103 -0
  62. package/dist/mcp-ui-spec/dist/schemas.cjs.map +1 -1
  63. package/dist/mcp-ui-spec/dist/schemas.js +103 -0
  64. package/dist/mcp-ui-spec/dist/schemas.js.map +1 -1
  65. package/docs/briefs/ROADMAP-opendata-macro-mcpui.md +912 -0
  66. package/package.json +17 -5
  67. package/src/adapters/connector.test.ts +165 -0
  68. package/src/adapters/connector.ts +234 -0
  69. package/src/adapters/index.ts +24 -0
  70. package/src/components/ExpandableWrapper.test.tsx +5 -2
  71. package/src/components/ExpandableWrapper.tsx +8 -6
  72. package/src/components/FeedbackInline.test.tsx +6 -3
  73. package/src/components/FeedbackInline.tsx +8 -6
  74. package/src/components/PresentationFeedback.test.tsx +163 -0
  75. package/src/components/PresentationFeedback.tsx +326 -0
  76. package/src/components/StreamingUIRenderer.parity.test.tsx +158 -0
  77. package/src/components/StreamingUIRenderer.tsx +42 -166
  78. package/src/components/index.ts +10 -0
  79. package/src/context/MCPUIStringsContext.test.tsx +116 -0
  80. package/src/context/MCPUIStringsContext.tsx +128 -0
  81. package/src/index.ts +27 -0
  82. package/tsconfig.tsbuildinfo +1 -1
  83. package/vite.config.ts +1 -0
@@ -0,0 +1,163 @@
1
+ /**
2
+ * v6.6.0 — PresentationFeedback (R3 / D9 of ROADMAP-opendata-macro-mcpui).
3
+ *
4
+ * Coverage:
5
+ * 1. Resting step shows the verdict buttons
6
+ * 2. "Clear" submits a `readable` verdict immediately
7
+ * 3. "Not clear" opens the detail step (does NOT submit yet)
8
+ * 4. Detail step assembles problems + preferredLayout + comment into the payload
9
+ * 5. base fields (connectorId/toolName/queryHash/renderKind/layoutType) are carried
10
+ * 6. Submission is best-effort — a rejected onSubmit promise does not throw
11
+ * 7. Labels are overridable via the `labels` prop
12
+ * 8. The layout picker is hidden when no options are given
13
+ */
14
+
15
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
16
+ import { render, cleanup, fireEvent } from '@solidjs/testing-library'
17
+ import { PresentationFeedback } from './PresentationFeedback'
18
+ import type { ConnectorRenderFeedback } from '@seed-ship/mcp-ui-spec'
19
+
20
+ const BASE = {
21
+ connectorId: 'datagouv',
22
+ toolName: 'datagouv.search',
23
+ queryHash: 'a1b2c3d4',
24
+ renderKind: 'primary',
25
+ layoutType: 'table',
26
+ }
27
+
28
+ describe('PresentationFeedback (v6.6.0)', () => {
29
+ beforeEach(() => cleanup())
30
+
31
+ it('resting step shows both verdict buttons', () => {
32
+ const { container } = render(() => (
33
+ <PresentationFeedback {...BASE} onSubmit={() => {}} />
34
+ ))
35
+ expect(container.querySelector('[data-presentation-feedback-verdict="readable"]')).toBeTruthy()
36
+ expect(
37
+ container.querySelector('[data-presentation-feedback-verdict="not_readable"]')
38
+ ).toBeTruthy()
39
+ expect(container.querySelector('[data-presentation-feedback-step="idle"]')).toBeTruthy()
40
+ })
41
+
42
+ it('"Clear" submits a readable verdict immediately', () => {
43
+ const onSubmit = vi.fn()
44
+ const { container } = render(() => (
45
+ <PresentationFeedback {...BASE} onSubmit={onSubmit} />
46
+ ))
47
+ fireEvent.click(container.querySelector('[data-presentation-feedback-verdict="readable"]')!)
48
+ expect(onSubmit).toHaveBeenCalledTimes(1)
49
+ const payload = onSubmit.mock.calls[0][0] as ConnectorRenderFeedback
50
+ expect(payload.verdict).toBe('readable')
51
+ expect(payload.connectorId).toBe('datagouv')
52
+ expect(payload.queryHash).toBe('a1b2c3d4')
53
+ expect(payload.renderKind).toBe('primary')
54
+ expect(payload.layoutType).toBe('table')
55
+ })
56
+
57
+ it('"Not clear" opens the detail step without submitting', () => {
58
+ const onSubmit = vi.fn()
59
+ const { container } = render(() => (
60
+ <PresentationFeedback {...BASE} onSubmit={onSubmit} />
61
+ ))
62
+ fireEvent.click(container.querySelector('[data-presentation-feedback-verdict="not_readable"]')!)
63
+ expect(onSubmit).not.toHaveBeenCalled()
64
+ expect(container.querySelector('[data-presentation-feedback-step="detail"]')).toBeTruthy()
65
+ expect(container.querySelector('[data-presentation-feedback-submit]')).toBeTruthy()
66
+ })
67
+
68
+ it('detail step assembles problems + preferredLayout + comment', () => {
69
+ const onSubmit = vi.fn()
70
+ const { container } = render(() => (
71
+ <PresentationFeedback
72
+ {...BASE}
73
+ onSubmit={onSubmit}
74
+ preferredLayoutOptions={['table', 'bar', 'map']}
75
+ />
76
+ ))
77
+ fireEvent.click(container.querySelector('[data-presentation-feedback-verdict="not_readable"]')!)
78
+ fireEvent.click(container.querySelector('[data-presentation-feedback-problem="too_raw"]')!)
79
+ fireEvent.click(
80
+ container.querySelector('[data-presentation-feedback-problem="wrong_columns"]')!
81
+ )
82
+ fireEvent.click(container.querySelector('[data-presentation-feedback-layout="bar"]')!)
83
+ const textarea = container.querySelector('[data-presentation-feedback-comment]') as HTMLTextAreaElement
84
+ fireEvent.input(textarea, { target: { value: 'prefer a chart' } })
85
+ fireEvent.click(container.querySelector('[data-presentation-feedback-submit]')!)
86
+
87
+ expect(onSubmit).toHaveBeenCalledTimes(1)
88
+ const payload = onSubmit.mock.calls[0][0] as ConnectorRenderFeedback
89
+ expect(payload.verdict).toBe('not_readable')
90
+ expect(payload.problems).toEqual(['too_raw', 'wrong_columns'])
91
+ expect(payload.preferredLayout).toBe('bar')
92
+ expect(payload.comment).toBe('prefer a chart')
93
+ })
94
+
95
+ it('deselecting a problem chip removes it from the payload', () => {
96
+ const onSubmit = vi.fn()
97
+ const { container } = render(() => (
98
+ <PresentationFeedback {...BASE} onSubmit={onSubmit} />
99
+ ))
100
+ fireEvent.click(container.querySelector('[data-presentation-feedback-verdict="not_readable"]')!)
101
+ const chip = container.querySelector('[data-presentation-feedback-problem="wrong_chart"]')!
102
+ fireEvent.click(chip) // select
103
+ fireEvent.click(chip) // deselect
104
+ fireEvent.click(container.querySelector('[data-presentation-feedback-submit]')!)
105
+ const payload = onSubmit.mock.calls[0][0] as ConnectorRenderFeedback
106
+ expect(payload.problems).toBeUndefined()
107
+ })
108
+
109
+ it('flips to the ack step after submitting', () => {
110
+ const { container } = render(() => (
111
+ <PresentationFeedback {...BASE} onSubmit={() => {}} />
112
+ ))
113
+ fireEvent.click(container.querySelector('[data-presentation-feedback-verdict="readable"]')!)
114
+ expect(container.querySelector('[data-presentation-feedback-ack]')).toBeTruthy()
115
+ })
116
+
117
+ it('submission is best-effort — a rejected onSubmit promise does not throw', () => {
118
+ const onSubmit = vi.fn(() => Promise.reject(new Error('network down')))
119
+ const { container } = render(() => (
120
+ <PresentationFeedback {...BASE} onSubmit={onSubmit} />
121
+ ))
122
+ expect(() =>
123
+ fireEvent.click(container.querySelector('[data-presentation-feedback-verdict="readable"]')!)
124
+ ).not.toThrow()
125
+ expect(container.querySelector('[data-presentation-feedback-ack]')).toBeTruthy()
126
+ })
127
+
128
+ it('labels are overridable via the labels prop', () => {
129
+ const { container } = render(() => (
130
+ <PresentationFeedback
131
+ {...BASE}
132
+ onSubmit={() => {}}
133
+ labels={{ readable: 'Lisible', notReadable: 'Pas lisible' }}
134
+ />
135
+ ))
136
+ const readableBtn = container.querySelector(
137
+ '[data-presentation-feedback-verdict="readable"]'
138
+ )
139
+ expect(readableBtn?.textContent).toBe('Lisible')
140
+ })
141
+
142
+ it('hides the layout picker when no options are given', () => {
143
+ const { container } = render(() => (
144
+ <PresentationFeedback {...BASE} onSubmit={() => {}} />
145
+ ))
146
+ fireEvent.click(container.querySelector('[data-presentation-feedback-verdict="not_readable"]')!)
147
+ expect(container.querySelector('[data-presentation-feedback-layout]')).toBeNull()
148
+ })
149
+
150
+ it('omits queryHash from the payload when not provided', () => {
151
+ const onSubmit = vi.fn()
152
+ const { container } = render(() => (
153
+ <PresentationFeedback
154
+ connectorId="c"
155
+ toolName="t"
156
+ onSubmit={onSubmit}
157
+ />
158
+ ))
159
+ fireEvent.click(container.querySelector('[data-presentation-feedback-verdict="readable"]')!)
160
+ const payload = onSubmit.mock.calls[0][0] as ConnectorRenderFeedback
161
+ expect(payload.queryHash).toBeUndefined()
162
+ })
163
+ })
@@ -0,0 +1,326 @@
1
+ /**
2
+ * PresentationFeedback — feedback on how a connector result was *presented*.
3
+ *
4
+ * @experimental
5
+ * @since v6.6.0 (R3 / D9 of ROADMAP-opendata-macro-mcpui)
6
+ *
7
+ * ## A separate axis from `FeedbackInline`
8
+ *
9
+ * MCP-UI has two distinct feedback widgets, kept separate on purpose
10
+ * (cf. R3) :
11
+ *
12
+ * - **`FeedbackInline`** — was the *answer* good? (response quality)
13
+ * - **`PresentationFeedback`** (this) — was the answer *shown well*?
14
+ * (layout / readability)
15
+ *
16
+ * They are separate components, separate exports, separate payloads — so
17
+ * the two axes never collapse into one in the UX or in the logs.
18
+ *
19
+ * ## Stateless — host owns persistence and re-render
20
+ *
21
+ * On submit the component calls `onSubmit(feedback)` with a
22
+ * `ConnectorRenderFeedback` payload and flips to its acknowledgement state.
23
+ * It does NOT persist anything and does NOT re-render the result itself
24
+ * (cf. D1 : adapter pure + host-owned state). The host persists the
25
+ * feedback and, if it wants to "close the loop", re-runs its adapter with
26
+ * the corrected `preferredLayout`.
27
+ *
28
+ * Submission is best-effort : a rejected `onSubmit` promise is swallowed,
29
+ * the UI still flips.
30
+ *
31
+ * ## Localization
32
+ *
33
+ * All labels ship in English and are overridable via the `labels` prop
34
+ * (partial). This component carries its own label bag rather than routing
35
+ * through `MCPUIStringsProvider` — its label set is large and specific to
36
+ * this one widget.
37
+ *
38
+ * @example
39
+ * ```tsx
40
+ * <PresentationFeedback
41
+ * connectorId="datagouv"
42
+ * toolName="datagouv.search"
43
+ * queryHash={result.queryHash}
44
+ * layoutType="table"
45
+ * preferredLayoutOptions={['table', 'bar', 'map']}
46
+ * onSubmit={(fb) => fetch('/api/render-feedback', {
47
+ * method: 'POST', body: JSON.stringify(fb),
48
+ * })}
49
+ * />
50
+ * ```
51
+ */
52
+
53
+ import { Component, Show, For, createSignal } from 'solid-js'
54
+ import type {
55
+ ConnectorRenderFeedback,
56
+ ConnectorRenderProblem,
57
+ ConnectorPreferredLayout,
58
+ } from '@seed-ship/mcp-ui-spec'
59
+
60
+ /** The full set of problem tags, in display order. */
61
+ const PROBLEM_ORDER: ConnectorRenderProblem[] = [
62
+ 'too_raw',
63
+ 'wrong_columns',
64
+ 'wrong_chart',
65
+ 'missing_context',
66
+ 'wrong_unit',
67
+ 'bad_grouping',
68
+ 'missing_dataset_context',
69
+ ]
70
+
71
+ export interface PresentationFeedbackLabels {
72
+ /** Resting-state question. */
73
+ prompt: string
74
+ /** Positive verdict button. */
75
+ readable: string
76
+ /** Negative verdict button — opens the detail step. */
77
+ notReadable: string
78
+ /** Heading of the detail step. */
79
+ problemsPrompt: string
80
+ /** Per-problem chip labels. */
81
+ problemTooRaw: string
82
+ problemWrongColumns: string
83
+ problemWrongChart: string
84
+ problemMissingContext: string
85
+ problemWrongUnit: string
86
+ problemBadGrouping: string
87
+ problemMissingDatasetContext: string
88
+ /** Heading of the optional layout picker. */
89
+ preferLayoutPrompt: string
90
+ /** Free-text comment placeholder. */
91
+ commentPlaceholder: string
92
+ /** Submit button of the detail step. */
93
+ submit: string
94
+ /** Acknowledgement shown after submission. */
95
+ ack: string
96
+ }
97
+
98
+ /** English defaults. Override via the `labels` prop for other locales. */
99
+ export const DEFAULT_PRESENTATION_FEEDBACK_LABELS: PresentationFeedbackLabels = {
100
+ prompt: 'Is this shown clearly?',
101
+ readable: 'Clear',
102
+ notReadable: 'Not clear',
103
+ problemsPrompt: "What's off?",
104
+ problemTooRaw: 'Too raw',
105
+ problemWrongColumns: 'Wrong columns',
106
+ problemWrongChart: 'Wrong chart',
107
+ problemMissingContext: 'Missing context',
108
+ problemWrongUnit: 'Wrong unit',
109
+ problemBadGrouping: 'Bad grouping',
110
+ problemMissingDatasetContext: 'Missing dataset context',
111
+ preferLayoutPrompt: 'Better shown as',
112
+ commentPlaceholder: 'Anything else? (optional)',
113
+ submit: 'Send feedback',
114
+ ack: 'Thanks — noted.',
115
+ }
116
+
117
+ const PROBLEM_LABEL_KEY: Record<ConnectorRenderProblem, keyof PresentationFeedbackLabels> = {
118
+ too_raw: 'problemTooRaw',
119
+ wrong_columns: 'problemWrongColumns',
120
+ wrong_chart: 'problemWrongChart',
121
+ missing_context: 'problemMissingContext',
122
+ wrong_unit: 'problemWrongUnit',
123
+ bad_grouping: 'problemBadGrouping',
124
+ missing_dataset_context: 'problemMissingDatasetContext',
125
+ }
126
+
127
+ export interface PresentationFeedbackProps {
128
+ /** Connector whose result is being rated. */
129
+ connectorId: string
130
+ /** Tool that produced the result. */
131
+ toolName: string
132
+ /** Stable key tying feedback to a `ConnectorDynamicResultV1.queryHash`. */
133
+ queryHash?: string
134
+ /** What is being rated (e.g. `'primary'`). Passed through to the payload. */
135
+ renderKind?: string
136
+ /** The layout type currently shown (e.g. `'table'`). Passed through. */
137
+ layoutType?: string
138
+ /**
139
+ * Called once on submit with the assembled `ConnectorRenderFeedback`.
140
+ * Persistence + any re-render are the host's responsibility.
141
+ */
142
+ onSubmit: (feedback: ConnectorRenderFeedback) => void | Promise<void>
143
+ /**
144
+ * Layout alternatives offered in the detail step. Omit (or pass an empty
145
+ * array) to hide the layout picker entirely.
146
+ */
147
+ preferredLayoutOptions?: ConnectorPreferredLayout[]
148
+ /** Partial label override (English defaults otherwise). */
149
+ labels?: Partial<PresentationFeedbackLabels>
150
+ /** Extra Tailwind classes on the container. */
151
+ class?: string
152
+ }
153
+
154
+ /**
155
+ * @experimental
156
+ * Presentation-quality feedback widget (readable / not-readable + detail).
157
+ */
158
+ export const PresentationFeedback: Component<PresentationFeedbackProps> = (props) => {
159
+ const [step, setStep] = createSignal<'idle' | 'detail' | 'done'>('idle')
160
+ const [problems, setProblems] = createSignal<Set<ConnectorRenderProblem>>(new Set())
161
+ const [preferred, setPreferred] = createSignal<ConnectorPreferredLayout | undefined>(undefined)
162
+ const [comment, setComment] = createSignal('')
163
+
164
+ const label = (key: keyof PresentationFeedbackLabels): string =>
165
+ props.labels?.[key] ?? DEFAULT_PRESENTATION_FEEDBACK_LABELS[key]
166
+
167
+ const emit = (feedback: ConnectorRenderFeedback) => {
168
+ try {
169
+ // Fire-and-forget — feedback is best-effort, a rejection must not
170
+ // break the UI (mirrors FeedbackInline).
171
+ const result = props.onSubmit(feedback)
172
+ if (result && typeof (result as Promise<void>).catch === 'function') {
173
+ ;(result as Promise<void>).catch(() => {
174
+ /* non-blocking */
175
+ })
176
+ }
177
+ } catch {
178
+ /* non-blocking */
179
+ }
180
+ setStep('done')
181
+ }
182
+
183
+ const base = (): Pick<
184
+ ConnectorRenderFeedback,
185
+ 'connectorId' | 'toolName' | 'queryHash' | 'renderKind' | 'layoutType'
186
+ > => ({
187
+ connectorId: props.connectorId,
188
+ toolName: props.toolName,
189
+ ...(props.queryHash ? { queryHash: props.queryHash } : {}),
190
+ ...(props.renderKind ? { renderKind: props.renderKind } : {}),
191
+ ...(props.layoutType ? { layoutType: props.layoutType } : {}),
192
+ })
193
+
194
+ const submitReadable = () => emit({ ...base(), verdict: 'readable' })
195
+
196
+ const submitNotReadable = () => {
197
+ const picked = [...problems()]
198
+ const text = comment().trim()
199
+ emit({
200
+ ...base(),
201
+ verdict: 'not_readable',
202
+ ...(picked.length > 0 ? { problems: picked } : {}),
203
+ ...(preferred() ? { preferredLayout: preferred() } : {}),
204
+ ...(text ? { comment: text } : {}),
205
+ })
206
+ }
207
+
208
+ const toggleProblem = (p: ConnectorRenderProblem) => {
209
+ setProblems((prev) => {
210
+ const next = new Set(prev)
211
+ if (next.has(p)) next.delete(p)
212
+ else next.add(p)
213
+ return next
214
+ })
215
+ }
216
+
217
+ return (
218
+ <div
219
+ class={`text-xs ${props.class ?? ''}`.trim()}
220
+ data-presentation-feedback-step={step()}
221
+ >
222
+ {/* ── Step 1 : verdict ─────────────────────────────── */}
223
+ <Show when={step() === 'idle'}>
224
+ <div class="flex items-center gap-2">
225
+ <span class="text-deposium-slate-500">{label('prompt')}</span>
226
+ <button
227
+ type="button"
228
+ onClick={submitReadable}
229
+ class="px-2 py-0.5 rounded border border-gray-200 dark:border-gray-600 text-deposium-slate-600 dark:text-gray-300 hover:border-green-500 hover:text-green-600 transition-colors"
230
+ data-presentation-feedback-verdict="readable"
231
+ >
232
+ {label('readable')}
233
+ </button>
234
+ <button
235
+ type="button"
236
+ onClick={() => setStep('detail')}
237
+ class="px-2 py-0.5 rounded border border-gray-200 dark:border-gray-600 text-deposium-slate-600 dark:text-gray-300 hover:border-amber-500 hover:text-amber-600 transition-colors"
238
+ data-presentation-feedback-verdict="not_readable"
239
+ >
240
+ {label('notReadable')}
241
+ </button>
242
+ </div>
243
+ </Show>
244
+
245
+ {/* ── Step 2 : detail ──────────────────────────────── */}
246
+ <Show when={step() === 'detail'}>
247
+ <div class="flex flex-col gap-2 p-2 rounded-lg border border-gray-200 dark:border-gray-700">
248
+ <span class="font-medium text-deposium-slate-600 dark:text-gray-300">
249
+ {label('problemsPrompt')}
250
+ </span>
251
+
252
+ {/* Problem chips */}
253
+ <div class="flex flex-wrap gap-1">
254
+ <For each={PROBLEM_ORDER}>
255
+ {(p) => (
256
+ <button
257
+ type="button"
258
+ onClick={() => toggleProblem(p)}
259
+ aria-pressed={problems().has(p)}
260
+ data-presentation-feedback-problem={p}
261
+ class={`px-2 py-0.5 rounded-full border transition-colors ${
262
+ problems().has(p)
263
+ ? 'border-amber-500 bg-amber-50 dark:bg-amber-900/30 text-amber-700 dark:text-amber-200'
264
+ : 'border-gray-200 dark:border-gray-600 text-deposium-slate-500 hover:border-amber-400'
265
+ }`}
266
+ >
267
+ {label(PROBLEM_LABEL_KEY[p])}
268
+ </button>
269
+ )}
270
+ </For>
271
+ </div>
272
+
273
+ {/* Optional layout picker */}
274
+ <Show when={(props.preferredLayoutOptions?.length ?? 0) > 0}>
275
+ <div class="flex flex-wrap items-center gap-1">
276
+ <span class="text-deposium-slate-500">{label('preferLayoutPrompt')}</span>
277
+ <For each={props.preferredLayoutOptions}>
278
+ {(opt) => (
279
+ <button
280
+ type="button"
281
+ onClick={() => setPreferred((cur) => (cur === opt ? undefined : opt))}
282
+ aria-pressed={preferred() === opt}
283
+ data-presentation-feedback-layout={opt}
284
+ class={`px-2 py-0.5 rounded border transition-colors ${
285
+ preferred() === opt
286
+ ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-200'
287
+ : 'border-gray-200 dark:border-gray-600 text-deposium-slate-500 hover:border-blue-400'
288
+ }`}
289
+ >
290
+ {opt}
291
+ </button>
292
+ )}
293
+ </For>
294
+ </div>
295
+ </Show>
296
+
297
+ {/* Free-text comment */}
298
+ <textarea
299
+ value={comment()}
300
+ onInput={(e) => setComment(e.currentTarget.value)}
301
+ placeholder={label('commentPlaceholder')}
302
+ rows={2}
303
+ class="w-full rounded border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-800 p-1.5 text-xs resize-y"
304
+ data-presentation-feedback-comment
305
+ />
306
+
307
+ <button
308
+ type="button"
309
+ onClick={submitNotReadable}
310
+ class="self-end px-3 py-1 rounded bg-amber-500 text-white font-medium hover:bg-amber-600 transition-colors"
311
+ data-presentation-feedback-submit
312
+ >
313
+ {label('submit')}
314
+ </button>
315
+ </div>
316
+ </Show>
317
+
318
+ {/* ── Step 3 : acknowledgement ─────────────────────── */}
319
+ <Show when={step() === 'done'}>
320
+ <span class="text-deposium-slate-500" data-presentation-feedback-ack>
321
+ {label('ack')}
322
+ </span>
323
+ </Show>
324
+ </div>
325
+ )
326
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * v6.6.0 — Streaming/static rendering parity (Gap 1 of ROADMAP-opendata-macro-mcpui).
3
+ *
4
+ * Before v6.6.0, `StreamingUIRenderer` used an inline simplified renderer
5
+ * (`StreamingComponentRenderer`) that only showed a component's type label +
6
+ * title — a streamed `table` did NOT render a real `<table>`. v6.6.0 deletes
7
+ * that and delegates each streamed `UIComponent` to the real
8
+ * `<UIResourceRenderer>`.
9
+ *
10
+ * These tests pin the parity : a component rendered through the streaming
11
+ * path produces the SAME functional DOM as the static path, and the legacy
12
+ * "type + title placeholder" markers are gone.
13
+ *
14
+ * `useStreamingUI` is mocked so components can be injected directly, without
15
+ * standing up an SSE endpoint.
16
+ */
17
+
18
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
19
+ import { render, cleanup } from '@solidjs/testing-library'
20
+
21
+ // ── Mock the SSE hook : inject a fixed component list ─────────────
22
+ const streamedComponents = vi.fn<() => unknown[]>(() => [])
23
+
24
+ vi.mock('../hooks/useStreamingUI', () => ({
25
+ useStreamingUI: () => ({
26
+ components: streamedComponents,
27
+ isLoading: () => false,
28
+ isStreaming: () => false,
29
+ error: () => null,
30
+ progress: () => ({ message: '', receivedCount: 0, totalCount: null }),
31
+ metadata: () => null,
32
+ startStreaming: () => {},
33
+ stopStreaming: () => {},
34
+ }),
35
+ }))
36
+
37
+ // Imported AFTER the mock declaration so the mock is in effect.
38
+ const { StreamingUIRenderer } = await import('./StreamingUIRenderer')
39
+ const { UIResourceRenderer } = await import('./UIResourceRenderer')
40
+
41
+ const TABLE_COMPONENT = {
42
+ id: 'tbl-1',
43
+ type: 'table' as const,
44
+ position: { colStart: 1, colSpan: 12 },
45
+ params: {
46
+ title: 'Prix au m2',
47
+ columns: [
48
+ { key: 'ville', label: 'Ville' },
49
+ { key: 'prix', label: 'Prix/m2' },
50
+ ],
51
+ rows: [
52
+ { ville: 'Toulouse', prix: 3200 },
53
+ { ville: 'Montpellier', prix: 3600 },
54
+ ],
55
+ },
56
+ }
57
+
58
+ const METRIC_COMPONENT = {
59
+ id: 'mtr-1',
60
+ type: 'metric' as const,
61
+ position: { colStart: 1, colSpan: 4 },
62
+ params: { title: 'Prix median', value: 3400, unit: 'EUR/m2' },
63
+ }
64
+
65
+ const ACTION_GROUP_COMPONENT = {
66
+ id: 'ag-1',
67
+ type: 'action-group' as const,
68
+ position: { colStart: 1, colSpan: 12 },
69
+ params: {
70
+ actions: [
71
+ { label: 'Comparer avec Paris', action: 'tool-call', toolName: 'datagouv.search' },
72
+ { label: 'Charger le dataset', action: 'tool-call', toolName: 'datagouv.load' },
73
+ ],
74
+ },
75
+ }
76
+
77
+ describe('StreamingUIRenderer parity (v6.6.0)', () => {
78
+ beforeEach(() => {
79
+ cleanup()
80
+ streamedComponents.mockReturnValue([])
81
+ })
82
+
83
+ it('streams a real <table>, not a type-label placeholder', () => {
84
+ streamedComponents.mockReturnValue([TABLE_COMPONENT])
85
+ const { container } = render(() => (
86
+ <StreamingUIRenderer query="immobilier toulouse" spaceIds={[]} />
87
+ ))
88
+ // Real TableRenderer output — an actual <table> with the rows.
89
+ const table = container.querySelector('table')
90
+ expect(table).toBeTruthy()
91
+ expect(container.textContent).toContain('Toulouse')
92
+ expect(container.textContent).toContain('Montpellier')
93
+ })
94
+
95
+ it('does NOT emit the legacy simplified-renderer markers', () => {
96
+ streamedComponents.mockReturnValue([TABLE_COMPONENT])
97
+ const { container } = render(() => (
98
+ <StreamingUIRenderer query="immobilier toulouse" spaceIds={[]} />
99
+ ))
100
+ // The pre-v6.6.0 inline renderer printed "Component ID: xxxxxxxx..."
101
+ expect(container.textContent).not.toContain('Component ID:')
102
+ })
103
+
104
+ it('streamed table DOM matches the static UIResourceRenderer path', () => {
105
+ streamedComponents.mockReturnValue([TABLE_COMPONENT])
106
+ const streamed = render(() => (
107
+ <StreamingUIRenderer query="q" spaceIds={[]} />
108
+ ))
109
+ const streamedTableCells = streamed.container.querySelectorAll('table td, table th').length
110
+
111
+ cleanup()
112
+
113
+ const staticRender = render(() => <UIResourceRenderer content={TABLE_COMPONENT} />)
114
+ const staticTableCells = staticRender.container.querySelectorAll('table td, table th').length
115
+
116
+ expect(streamedTableCells).toBeGreaterThan(0)
117
+ expect(streamedTableCells).toBe(staticTableCells)
118
+ })
119
+
120
+ it('streams a real metric value', () => {
121
+ streamedComponents.mockReturnValue([METRIC_COMPONENT])
122
+ const { container } = render(() => (
123
+ <StreamingUIRenderer query="prix" spaceIds={[]} />
124
+ ))
125
+ expect(container.textContent).toContain('3400')
126
+ expect(container.textContent).toContain('Prix median')
127
+ })
128
+
129
+ it('streams a real action-group with clickable buttons', () => {
130
+ streamedComponents.mockReturnValue([ACTION_GROUP_COMPONENT])
131
+ const { container } = render(() => (
132
+ <StreamingUIRenderer query="immobilier" spaceIds={[]} />
133
+ ))
134
+ const buttons = Array.from(container.querySelectorAll('button'))
135
+ const labels = buttons.map((b) => b.textContent?.trim())
136
+ expect(labels).toContain('Comparer avec Paris')
137
+ expect(labels).toContain('Charger le dataset')
138
+ })
139
+
140
+ it('renders every streamed component (multi-component layout)', () => {
141
+ streamedComponents.mockReturnValue([TABLE_COMPONENT, METRIC_COMPONENT, ACTION_GROUP_COMPONENT])
142
+ const { container } = render(() => (
143
+ <StreamingUIRenderer query="dashboard" spaceIds={[]} />
144
+ ))
145
+ expect(container.querySelector('table')).toBeTruthy()
146
+ expect(container.textContent).toContain('Prix median')
147
+ expect(container.textContent).toContain('Comparer avec Paris')
148
+ })
149
+
150
+ it('carries data-mcp-ui-component-id on streamed components (v6.5.0 identity)', () => {
151
+ streamedComponents.mockReturnValue([TABLE_COMPONENT])
152
+ const { container } = render(() => (
153
+ <StreamingUIRenderer query="q" spaceIds={[]} />
154
+ ))
155
+ // Delegation to UIResourceRenderer means the v6.5.0 identity attrs apply.
156
+ expect(container.querySelector('[data-mcp-ui-component-id="tbl-1"]')).toBeTruthy()
157
+ })
158
+ })