@seed-ship/mcp-ui-solid 4.0.0 → 4.0.2

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.
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * DataPreviewSection — paginated data table with export
3
- * v3.1.0: Replaces LLM-generated markdown tables with exact source data
3
+ * v4.0.1: Fixed rendering defensive guards for store proxy content
4
4
  *
5
5
  * @experimental
6
6
  *
@@ -22,7 +22,6 @@ export interface DataPreviewSectionProps {
22
22
  /** Format a number for display (French locale) */
23
23
  function formatNumber(value: unknown, format?: string): string {
24
24
  if (typeof value !== 'number' || !isFinite(value)) return String(value ?? '')
25
- // Simple formatting: use locale
26
25
  if (format === 'percent') return `${(value * 100).toFixed(1)}%`
27
26
  if (format === 'currency') return `${value.toLocaleString('fr-FR')} EUR`
28
27
  if (Number.isInteger(value)) return value.toLocaleString('fr-FR')
@@ -31,7 +30,7 @@ function formatNumber(value: unknown, format?: string): string {
31
30
 
32
31
  /** Format a cell value based on column type */
33
32
  function formatCell(value: unknown, col: DataPreviewColumn): string {
34
- if (value == null) return ''
33
+ if (value == null) return '\u2014'
35
34
  if (col.type === 'number') return formatNumber(value, col.format)
36
35
  if (col.type === 'date' && typeof value === 'string') {
37
36
  try {
@@ -68,26 +67,64 @@ function downloadFile(content: string, filename: string, mimeType: string) {
68
67
  URL.revokeObjectURL(url)
69
68
  }
70
69
 
70
+ /**
71
+ * Extract a valid DataPreviewContent from props.content.
72
+ * Handles: direct DataPreviewContent, or wrapped in an extra layer.
73
+ */
74
+ function resolveContent(raw: unknown): DataPreviewContent | null {
75
+ if (!raw || typeof raw !== 'object') return null
76
+ const obj = raw as Record<string, unknown>
77
+
78
+ // Direct shape: { columns: [...], rows: [...] }
79
+ if (Array.isArray(obj.columns) && Array.isArray(obj.rows)) {
80
+ return obj as unknown as DataPreviewContent
81
+ }
82
+
83
+ // Wrapped shape: { content: { columns: [...], rows: [...] } }
84
+ if (obj.content && typeof obj.content === 'object') {
85
+ const inner = obj.content as Record<string, unknown>
86
+ if (Array.isArray(inner.columns) && Array.isArray(inner.rows)) {
87
+ return inner as unknown as DataPreviewContent
88
+ }
89
+ }
90
+
91
+ return null
92
+ }
93
+
71
94
  export function DataPreviewSection(props: DataPreviewSectionProps) {
72
- const content = () => props.content
73
- const pageSize = () => content().pageSize || 25
95
+ const content = createMemo(() => {
96
+ const resolved = resolveContent(props.content)
97
+ if (!resolved) {
98
+ console.warn(
99
+ '[MCP-UI] DataPreviewSection: invalid content — expected { columns: [...], rows: [...] }, got:',
100
+ props.content
101
+ )
102
+ }
103
+ return resolved
104
+ })
105
+
106
+ const columns = () => content()?.columns || []
107
+ const rows = () => content()?.rows || []
108
+ const pageSize = () => content()?.pageSize || 25
74
109
  const [page, setPage] = createSignal(0)
75
110
 
76
- const totalRows = () => content().rows.length
111
+ const totalRows = () => rows().length
77
112
  const totalPages = () => Math.max(1, Math.ceil(totalRows() / pageSize()))
78
113
 
79
114
  const pagedRows = createMemo(() => {
80
115
  const start = page() * pageSize()
81
- return content().rows.slice(start, start + pageSize())
116
+ return rows().slice(start, start + pageSize())
82
117
  })
83
118
 
84
119
  const handleExportCSV = () => {
85
- const csv = toCSV(content().columns, content().rows)
120
+ const c = content()
121
+ if (!c) return
122
+ const csv = toCSV(c.columns, c.rows)
86
123
  downloadFile(csv, 'data-export.csv', 'text/csv;charset=utf-8')
87
124
  }
88
125
 
89
126
  const handleExportJSON = () => {
90
- const json = JSON.stringify(content().rows, null, 2)
127
+ const json = JSON.stringify(rows(), null, 2)
91
128
  downloadFile(json, 'data-export.json', 'application/json')
92
129
  }
93
130
 
@@ -98,109 +135,117 @@ export function DataPreviewSection(props: DataPreviewSectionProps) {
98
135
  }
99
136
 
100
137
  return (
101
- <div class="data-preview-section">
102
- {/* Header with source + export */}
103
- <div class="flex items-center justify-between mb-2">
104
- <div class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
105
- <Show when={content().source}>
106
- <span class="font-medium">{content().source}</span>
107
- </Show>
108
- <Show when={content().freshness}>
109
- <span class="px-1.5 py-0.5 rounded bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300">
110
- {content().freshness}
111
- </span>
112
- </Show>
113
- </div>
114
-
115
- <Show when={content().exportable !== false}>
116
- <div class="flex items-center gap-1">
117
- <button
118
- class="px-2 py-1 text-xs rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
119
- onClick={handleExportCSV}
120
- title="Export CSV"
121
- >
122
- CSV
123
- </button>
124
- <button
125
- class="px-2 py-1 text-xs rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
126
- onClick={handleExportJSON}
127
- title="Export JSON"
128
- >
129
- JSON
130
- </button>
131
- </div>
132
- </Show>
138
+ <Show when={content()} fallback={
139
+ <div class="text-xs text-amber-600 dark:text-amber-400 p-2">
140
+ [DataPreviewSection] Invalid content format
133
141
  </div>
134
-
135
- {/* Table */}
136
- <div class="overflow-x-auto rounded border border-gray-200 dark:border-gray-700">
137
- <table class="w-full text-sm">
138
- <thead>
139
- <tr class="bg-gray-50 dark:bg-gray-800">
140
- <For each={content().columns}>
141
- {(col) => (
142
- <th
143
- class="px-3 py-2 font-medium text-xs text-gray-600 dark:text-gray-400 uppercase tracking-wider border-b border-gray-200 dark:border-gray-700"
144
- style={{ "text-align": columnAlign(col) }}
145
- >
146
- {col.label}
147
- </th>
148
- )}
149
- </For>
150
- </tr>
151
- </thead>
152
- <tbody>
153
- <For each={pagedRows()}>
154
- {(row, i) => (
155
- <tr
156
- class="border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors"
157
- classList={{ 'bg-gray-25 dark:bg-gray-850': i() % 2 === 1 }}
142
+ }>
143
+ {(c) => (
144
+ <div class="data-preview-section">
145
+ {/* Header with source + export */}
146
+ <div class="flex items-center justify-between mb-2">
147
+ <div class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
148
+ <Show when={c().source}>
149
+ <span class="font-medium">{c().source}</span>
150
+ </Show>
151
+ <Show when={c().freshness}>
152
+ <span class="px-1.5 py-0.5 rounded bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300">
153
+ {c().freshness}
154
+ </span>
155
+ </Show>
156
+ </div>
157
+
158
+ <Show when={c().exportable !== false}>
159
+ <div class="flex items-center gap-1">
160
+ <button
161
+ class="px-2 py-1 text-xs rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
162
+ onClick={handleExportCSV}
163
+ title="Export CSV"
164
+ >
165
+ CSV
166
+ </button>
167
+ <button
168
+ class="px-2 py-1 text-xs rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
169
+ onClick={handleExportJSON}
170
+ title="Export JSON"
158
171
  >
159
- <For each={content().columns}>
172
+ JSON
173
+ </button>
174
+ </div>
175
+ </Show>
176
+ </div>
177
+
178
+ {/* Table */}
179
+ <div class="overflow-x-auto rounded border border-gray-200 dark:border-gray-700">
180
+ <table class="w-full text-sm">
181
+ <thead>
182
+ <tr class="bg-gray-50 dark:bg-gray-800">
183
+ <For each={columns()}>
160
184
  {(col) => (
161
- <td
162
- class="px-3 py-2 text-gray-800 dark:text-gray-200"
185
+ <th
186
+ class="px-3 py-2 font-medium text-xs text-gray-600 dark:text-gray-400 uppercase tracking-wider border-b border-gray-200 dark:border-gray-700"
163
187
  style={{ "text-align": columnAlign(col) }}
164
188
  >
165
- {formatCell(row[col.key], col)}
166
- </td>
189
+ {col.label}
190
+ </th>
167
191
  )}
168
192
  </For>
169
193
  </tr>
170
- )}
171
- </For>
172
- </tbody>
173
- </table>
174
- </div>
194
+ </thead>
195
+ <tbody>
196
+ <For each={pagedRows()}>
197
+ {(row, i) => (
198
+ <tr
199
+ class="border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors"
200
+ classList={{ 'bg-gray-25 dark:bg-gray-850': i() % 2 === 1 }}
201
+ >
202
+ <For each={columns()}>
203
+ {(col) => (
204
+ <td
205
+ class="px-3 py-2 text-gray-800 dark:text-gray-200"
206
+ style={{ "text-align": columnAlign(col) }}
207
+ >
208
+ {formatCell(row[col.key], col)}
209
+ </td>
210
+ )}
211
+ </For>
212
+ </tr>
213
+ )}
214
+ </For>
215
+ </tbody>
216
+ </table>
217
+ </div>
218
+
219
+ {/* Footer: pagination + row count */}
220
+ <div class="flex items-center justify-between mt-2 text-xs text-gray-500 dark:text-gray-400">
221
+ <span>
222
+ {c().totalRows
223
+ ? `${totalRows()} / ${c().totalRows!.toLocaleString('fr-FR')} rows`
224
+ : `${totalRows()} row${totalRows() !== 1 ? 's' : ''}`}
225
+ </span>
175
226
 
176
- {/* Footer: pagination + row count */}
177
- <div class="flex items-center justify-between mt-2 text-xs text-gray-500 dark:text-gray-400">
178
- <span>
179
- {content().totalRows
180
- ? `${totalRows()} / ${content().totalRows!.toLocaleString('fr-FR')} rows`
181
- : `${totalRows()} row${totalRows() !== 1 ? 's' : ''}`}
182
- </span>
183
-
184
- <Show when={totalPages() > 1}>
185
- <div class="flex items-center gap-1">
186
- <button
187
- class="px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-40 transition-colors"
188
- disabled={page() === 0}
189
- onClick={() => setPage(p => p - 1)}
190
- >
191
- &laquo;
192
- </button>
193
- <span>{page() + 1} / {totalPages()}</span>
194
- <button
195
- class="px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-40 transition-colors"
196
- disabled={page() >= totalPages() - 1}
197
- onClick={() => setPage(p => p + 1)}
198
- >
199
- &raquo;
200
- </button>
227
+ <Show when={totalPages() > 1}>
228
+ <div class="flex items-center gap-1">
229
+ <button
230
+ class="px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-40 transition-colors"
231
+ disabled={page() === 0}
232
+ onClick={() => setPage(p => p - 1)}
233
+ >
234
+ &laquo;
235
+ </button>
236
+ <span>{page() + 1} / {totalPages()}</span>
237
+ <button
238
+ class="px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-40 transition-colors"
239
+ disabled={page() >= totalPages() - 1}
240
+ onClick={() => setPage(p => p + 1)}
241
+ >
242
+ &raquo;
243
+ </button>
244
+ </div>
245
+ </Show>
201
246
  </div>
202
- </Show>
203
- </div>
204
- </div>
247
+ </div>
248
+ )}
249
+ </Show>
205
250
  )
206
251
  }
@@ -342,6 +342,16 @@ export const ScratchpadPanel: Component<ScratchpadPanelProps> = (props) => {
342
342
  )
343
343
  }
344
344
 
345
+ // ─── Helpers ────────────────────────────────────────────────
346
+
347
+ /** Parse content that may arrive as a JSON string from SSE transport */
348
+ function parseContent(content: unknown): unknown {
349
+ if (typeof content === 'string') {
350
+ try { return JSON.parse(content) } catch { return content }
351
+ }
352
+ return content
353
+ }
354
+
345
355
  // ─── Section Renderer ────────────────────────────────────────
346
356
 
347
357
  const SectionRenderer: Component<{
@@ -356,23 +366,23 @@ const SectionRenderer: Component<{
356
366
  <div class="px-4 py-3">
357
367
  <h4 class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-2">{props.section.title}</h4>
358
368
  <Switch>
359
- <Match when={props.section.type === 'data'}><DataSection content={props.section.content} /></Match>
360
- <Match when={props.section.type === 'filter'}><InteractiveFilterSection content={props.section.content} filters={props.filters} onFilterChange={props.onFilterChange} /></Match>
369
+ <Match when={props.section.type === 'data'}><DataSection content={parseContent(props.section.content)} /></Match>
370
+ <Match when={props.section.type === 'filter'}><InteractiveFilterSection content={parseContent(props.section.content)} filters={props.filters} onFilterChange={props.onFilterChange} /></Match>
361
371
  <Match when={props.section.type === 'message'}><p class="text-sm text-gray-700 dark:text-gray-300">{String(props.section.content)}</p></Match>
362
- <Match when={props.section.type === 'action'}><ActionSection content={props.section.content} onAction={props.onAction} /></Match>
363
- <Match when={props.section.type === 'steps'}><EnrichedStepsSection content={props.section.content} onAction={props.onAction} onFilterChange={props.onFilterChange} /></Match>
364
- <Match when={props.section.type === 'form'}><EmbeddedFormSection content={props.section.content} sectionId={props.section.id} onAction={props.onAction} onSubmit={props.onSubmit} /></Match>
365
- <Match when={props.section.type === 'understanding'}><UnderstandingSection content={props.section.content} /></Match>
366
- <Match when={props.section.type === 'feedback'}><FeedbackSection content={props.section.content} onAction={props.onAction} /></Match>
367
- <Match when={props.section.type === 'prompt'}><PromptSection content={props.section.content} onAction={props.onAction} /></Match>
368
- <Match when={props.section.type === 'stepper'}><StepperProgressSection content={props.section.content} /></Match>
369
- <Match when={props.section.type === 'error'}><ErrorSectionRenderer content={props.section.content} onAction={props.onAction} /></Match>
370
- <Match when={props.section.type === 'source_card'}><SourceCardSection content={props.section.content} /></Match>
371
- <Match when={props.section.type === 'diff'}><DiffSection content={props.section.content} /></Match>
372
- <Match when={props.section.type === 'verified_text'}><VerifiedText {...(props.section.content as VerifiedTextContent)} onHallucinationClick={(h) => props.onAction?.('hallucination_click', h)} /></Match>
373
- <Match when={props.section.type === 'data_preview'}><DataPreviewSection content={props.section.content as DataPreviewContent} /></Match>
374
- <Match when={props.section.type === 'map'}>{(() => { const c = props.section.content as MapSectionContent; return <MapRenderer params={{ geojson: c.geojson, center: c.center, zoom: c.zoom, geojsonStyle: c.style, popup: c.popup, layers: c.layers, height: c.height || '300px', fitBounds: true }} /> })()}</Match>
375
- <Match when={props.section.type === 'chart'}><ChartJSRenderer component={{ id: props.section.id, type: 'chart', position: { colStart: 1, colSpan: 12 }, params: { ...(props.section.content as ChartComponentParams), renderer: 'native', height: (props.section.content as any)?.height || '250px' } }} /></Match>
372
+ <Match when={props.section.type === 'action'}><ActionSection content={parseContent(props.section.content)} onAction={props.onAction} /></Match>
373
+ <Match when={props.section.type === 'steps'}><EnrichedStepsSection content={parseContent(props.section.content)} onAction={props.onAction} onFilterChange={props.onFilterChange} /></Match>
374
+ <Match when={props.section.type === 'form'}><EmbeddedFormSection content={parseContent(props.section.content)} sectionId={props.section.id} onAction={props.onAction} onSubmit={props.onSubmit} /></Match>
375
+ <Match when={props.section.type === 'understanding'}><UnderstandingSection content={parseContent(props.section.content)} /></Match>
376
+ <Match when={props.section.type === 'feedback'}><FeedbackSection content={parseContent(props.section.content)} onAction={props.onAction} /></Match>
377
+ <Match when={props.section.type === 'prompt'}><PromptSection content={parseContent(props.section.content)} onAction={props.onAction} /></Match>
378
+ <Match when={props.section.type === 'stepper'}><StepperProgressSection content={parseContent(props.section.content)} /></Match>
379
+ <Match when={props.section.type === 'error'}><ErrorSectionRenderer content={parseContent(props.section.content)} onAction={props.onAction} /></Match>
380
+ <Match when={props.section.type === 'source_card'}><SourceCardSection content={parseContent(props.section.content)} /></Match>
381
+ <Match when={props.section.type === 'diff'}><DiffSection content={parseContent(props.section.content)} /></Match>
382
+ <Match when={props.section.type === 'verified_text'}><VerifiedText {...(parseContent(props.section.content) as VerifiedTextContent)} onHallucinationClick={(h) => props.onAction?.('hallucination_click', h)} /></Match>
383
+ <Match when={props.section.type === 'data_preview'}><DataPreviewSection content={parseContent(props.section.content) as DataPreviewContent} /></Match>
384
+ <Match when={props.section.type === 'map'}>{(() => { const c = parseContent(props.section.content) as MapSectionContent; return <MapRenderer params={{ geojson: c.geojson, center: c.center, zoom: c.zoom, geojsonStyle: c.style, popup: c.popup, layers: c.layers, height: c.height || '300px', fitBounds: true }} /> })()}</Match>
385
+ <Match when={props.section.type === 'chart'}>{(() => { const c = parseContent(props.section.content) as ChartComponentParams; return <ChartJSRenderer component={{ id: props.section.id, type: 'chart', position: { colStart: 1, colSpan: 12 }, params: { ...c, renderer: 'native', height: (c as any)?.height || '250px' } }} /> })()}</Match>
376
386
  <Match when={true}><pre class="text-xs text-gray-500 overflow-auto">{JSON.stringify(props.section.content, null, 2)}</pre></Match>
377
387
  </Switch>
378
388
  </div>
@@ -644,7 +654,15 @@ const ActionSection: Component<{
644
654
  content: unknown
645
655
  onAction?: (action: string, data?: unknown) => void
646
656
  }> = (props) => {
647
- const actions = () => Array.isArray(props.content) ? props.content as Array<{ label: string; value?: string; action?: string; variant?: string; icon?: string }> : []
657
+ const actions = () => {
658
+ if (Array.isArray(props.content)) return props.content as Array<{ label: string; value?: string; action?: string; variant?: string; icon?: string }>
659
+ const obj = props.content as Record<string, unknown> | null
660
+ if (obj && Array.isArray(obj.actions)) {
661
+ console.warn('[MCP-UI] ActionSection: content should be an array, got { actions: [...] }. Unwrapping automatically.')
662
+ return obj.actions as Array<{ label: string; value?: string; action?: string; variant?: string; icon?: string }>
663
+ }
664
+ return []
665
+ }
648
666
  return (
649
667
  <div class="flex flex-wrap gap-2">
650
668
  <For each={actions()}>