@seed-ship/mcp-ui-solid 2.8.3 → 2.9.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.
@@ -1,30 +1,24 @@
1
1
  /**
2
- * ScratchpadPanel v2 — HITL/AITL shared workspace
3
- * v2.8.2: Close button, collapsible, auto-close, empty state, animations
2
+ * ScratchpadPanel v3Full HITL/AITL workspace
3
+ * v2.9.0: Interactive filters, form sections, stepper, preview auto-refresh
4
4
  *
5
5
  * @experimental
6
6
  */
7
7
 
8
8
  import { Component, Show, For, Switch, Match, createSignal, createEffect, onCleanup } from 'solid-js'
9
9
  import type { ScratchpadState, ScratchpadSection } from '../types/chat-bus'
10
+ import type { FormFieldParams } from '../types'
11
+ import { FormFieldRenderer } from './FormFieldRenderer'
10
12
 
11
13
  export interface ScratchpadPanelProps {
12
14
  state: ScratchpadState
13
- /** Called when human modifies filters */
14
15
  onFilterChange?: (filters: Record<string, string | string[]>) => void
15
- /** Called when human clicks an action button */
16
16
  onAction?: (action: string, data?: unknown) => void
17
- /** Called when human edits a section */
18
17
  onSectionEdit?: (sectionId: string, content: unknown) => void
19
- /** Called when user closes the panel */
20
18
  onClose?: () => void
21
- /** Show close button (default: true) */
22
19
  closable?: boolean
23
- /** Auto-close delay in ms after status=complete (default: undefined = no auto-close) */
24
20
  autoCloseDelay?: number
25
- /** Allow collapsing body by clicking header (default: true) */
26
21
  collapsible?: boolean
27
- /** CSS max-height for scrollable body (default: "400px") */
28
22
  maxHeight?: string
29
23
  }
30
24
 
@@ -36,14 +30,15 @@ const STATUS_BADGES: Record<ScratchpadState['status'], { label: string; class: s
36
30
  complete: { label: 'Complete', class: 'bg-green-100 text-green-600 dark:bg-green-900/30 dark:text-green-400' },
37
31
  }
38
32
 
39
- /**
40
- * @experimental
41
- */
42
33
  export const ScratchpadPanel: Component<ScratchpadPanelProps> = (props) => {
43
34
  const [collapsed, setCollapsed] = createSignal(false)
35
+ const [localPreview, setLocalPreview] = createSignal<ScratchpadState['preview']>(undefined)
36
+ let previewTimer: ReturnType<typeof setTimeout> | null = null
44
37
  const badge = () => STATUS_BADGES[props.state.status] || STATUS_BADGES.loading
45
38
  const isClosable = () => props.closable !== false
46
39
  const isCollapsible = () => props.collapsible !== false
40
+ const preview = () => localPreview() || props.state.preview
41
+ const hasFilters = () => Object.keys(props.state.filters || {}).length > 0
47
42
 
48
43
  // Auto-close on complete
49
44
  createEffect(() => {
@@ -53,19 +48,42 @@ export const ScratchpadPanel: Component<ScratchpadPanelProps> = (props) => {
53
48
  }
54
49
  })
55
50
 
56
- const handleHeaderClick = () => {
57
- if (isCollapsible()) setCollapsed(!collapsed())
58
- }
51
+ // Preview auto-refresh when filters change
52
+ createEffect(() => {
53
+ const endpoint = props.state.previewEndpoint
54
+ if (!endpoint) return
55
+ const filters = props.state.filters
56
+ if (!filters || Object.keys(filters).length === 0) return
57
+
58
+ if (previewTimer) clearTimeout(previewTimer)
59
+ previewTimer = setTimeout(async () => {
60
+ try {
61
+ const res = await fetch(endpoint, {
62
+ method: 'POST',
63
+ headers: { 'Content-Type': 'application/json' },
64
+ credentials: 'include',
65
+ body: JSON.stringify({ filters }),
66
+ })
67
+ if (res.ok) setLocalPreview(await res.json())
68
+ } catch { /* ignore */ }
69
+ }, props.state.previewDebounce || 500)
70
+ })
71
+
72
+ onCleanup(() => { if (previewTimer) clearTimeout(previewTimer) })
59
73
 
60
74
  return (
61
75
  <div
62
- class="w-full bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-lg overflow-visible"
76
+ class={`w-full bg-white dark:bg-gray-800 rounded-xl border shadow-lg overflow-visible ${
77
+ props.state.status === 'waiting_human'
78
+ ? 'border-blue-300 dark:border-blue-600'
79
+ : 'border-gray-200 dark:border-gray-700'
80
+ }`}
63
81
  style={{ animation: 'scratchpad-slide-down 0.2s ease-out' }}
64
82
  >
65
83
  {/* Header */}
66
84
  <div
67
85
  class={`flex items-center justify-between px-4 py-3 border-b border-gray-100 dark:border-gray-700 ${isCollapsible() ? 'cursor-pointer select-none hover:bg-gray-50 dark:hover:bg-gray-750' : ''}`}
68
- onClick={handleHeaderClick}
86
+ onClick={() => isCollapsible() && setCollapsed(!collapsed())}
69
87
  >
70
88
  <div class="flex items-center gap-2">
71
89
  <span class="text-base">&#128221;</span>
@@ -77,26 +95,18 @@ export const ScratchpadPanel: Component<ScratchpadPanelProps> = (props) => {
77
95
  </Show>
78
96
  </div>
79
97
  <div class="flex items-center gap-2">
80
- <span class={`px-2 py-0.5 text-xs font-medium rounded-full ${badge().class}`}>
81
- {badge().label}
82
- </span>
98
+ <span class={`px-2 py-0.5 text-xs font-medium rounded-full ${badge().class}`}>{badge().label}</span>
83
99
  <Show when={isClosable() && props.onClose}>
84
- <button
85
- onClick={(e) => { e.stopPropagation(); props.onClose?.() }}
86
- class="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
87
- aria-label="Close scratchpad"
88
- >
89
- <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
90
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
91
- </svg>
100
+ <button onClick={(e) => { e.stopPropagation(); props.onClose?.() }} class="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors" aria-label="Close">
101
+ <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
92
102
  </button>
93
103
  </Show>
94
104
  </div>
95
105
  </div>
96
106
 
97
- {/* Body — collapsible */}
107
+ {/* Body */}
98
108
  <Show when={!collapsed()}>
99
- <div style={{ "max-height": props.maxHeight || "400px", "overflow-y": "auto" }}>
109
+ <div style={{ "max-height": props.maxHeight || "500px", "overflow-y": "auto" }}>
100
110
  {/* Sections */}
101
111
  <div class="divide-y divide-gray-100 dark:divide-gray-700">
102
112
  <For each={props.state.sections}>
@@ -122,9 +132,7 @@ export const ScratchpadPanel: Component<ScratchpadPanelProps> = (props) => {
122
132
  : msg.type === 'question' ? 'bg-blue-50 dark:bg-blue-900/10 text-blue-700 dark:text-blue-400 border border-blue-200 dark:border-blue-800'
123
133
  : 'bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-400'
124
134
  }`}>
125
- <span class="flex-shrink-0 mt-0.5">
126
- {msg.type === 'warning' ? '⚠️' : msg.type === 'question' ? '❓' : 'ℹ️'}
127
- </span>
135
+ <span class="flex-shrink-0 mt-0.5">{msg.type === 'warning' ? '⚠️' : msg.type === 'question' ? '❓' : 'ℹ️'}</span>
128
136
  <p>{msg.text}</p>
129
137
  </div>
130
138
  )}
@@ -133,68 +141,52 @@ export const ScratchpadPanel: Component<ScratchpadPanelProps> = (props) => {
133
141
  </Show>
134
142
 
135
143
  {/* Preview */}
136
- <Show when={props.state.preview}>
144
+ <Show when={preview()}>
137
145
  <div class="px-4 py-3 border-t border-gray-100 dark:border-gray-700">
138
- <Show when={props.state.preview!.count === 0} fallback={
146
+ <Show when={preview()!.count === 0} fallback={
139
147
  <>
140
148
  <div class="flex items-center gap-2 mb-2">
141
149
  <span class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Preview</span>
142
- <span class="px-1.5 py-0.5 text-xs font-bold bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded">
143
- {props.state.preview!.count.toLocaleString()}
144
- </span>
150
+ <span class="px-1.5 py-0.5 text-xs font-bold bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded">{preview()!.count.toLocaleString()}</span>
145
151
  </div>
146
- <p class="text-sm text-gray-700 dark:text-gray-300">{props.state.preview!.summary}</p>
147
- <Show when={props.state.preview!.rows && props.state.preview!.rows!.length > 0}>
152
+ <p class="text-sm text-gray-700 dark:text-gray-300">{preview()!.summary}</p>
153
+ <Show when={preview()!.rows && preview()!.rows!.length > 0}>
148
154
  <div class="mt-2 overflow-x-auto">
149
155
  <table class="min-w-full text-xs">
150
- <thead>
151
- <tr>
152
- <For each={Object.keys(props.state.preview!.rows![0])}>
153
- {(key) => <th class="px-2 py-1 text-left font-medium text-gray-500 dark:text-gray-400">{key}</th>}
154
- </For>
155
- </tr>
156
- </thead>
157
- <tbody>
158
- <For each={props.state.preview!.rows!.slice(0, 5)}>
159
- {(row) => (
160
- <tr class="border-t border-gray-100 dark:border-gray-700">
161
- <For each={Object.values(row)}>
162
- {(val) => <td class="px-2 py-1 text-gray-700 dark:text-gray-300">{String(val)}</td>}
163
- </For>
164
- </tr>
165
- )}
166
- </For>
167
- </tbody>
156
+ <thead><tr><For each={Object.keys(preview()!.rows![0])}>{(k) => <th class="px-2 py-1 text-left font-medium text-gray-500 dark:text-gray-400">{k}</th>}</For></tr></thead>
157
+ <tbody><For each={preview()!.rows!.slice(0, 5)}>{(row) => <tr class="border-t border-gray-100 dark:border-gray-700"><For each={Object.values(row)}>{(v) => <td class="px-2 py-1 text-gray-700 dark:text-gray-300">{String(v)}</td>}</For></tr>}</For></tbody>
168
158
  </table>
169
159
  </div>
170
160
  </Show>
171
161
  </>
172
162
  }>
173
- {/* Empty state */}
174
163
  <div class="flex flex-col items-center gap-2 py-4 text-center">
175
164
  <span class="text-2xl">&#128269;</span>
176
165
  <p class="text-sm text-gray-500 dark:text-gray-400">No results for these filters</p>
177
- <Show when={props.onFilterChange}>
178
- <button
179
- type="button"
180
- onClick={() => props.onAction?.('refine_filters')}
181
- class="px-3 py-1.5 text-xs font-medium text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg transition-colors"
182
- >
183
- Modify filters
184
- </button>
185
- </Show>
166
+ <button type="button" onClick={() => props.onAction?.('refine_filters')} class="px-3 py-1.5 text-xs font-medium text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg transition-colors">Modify filters</button>
186
167
  </div>
187
168
  </Show>
188
169
  </div>
189
170
  </Show>
171
+
172
+ {/* Search button when waiting_human */}
173
+ <Show when={props.state.status === 'waiting_human' && hasFilters()}>
174
+ <div class="px-4 py-3 border-t border-gray-100 dark:border-gray-700">
175
+ <button
176
+ type="button"
177
+ onClick={() => props.onAction?.('search', { filters: props.state.filters })}
178
+ class="w-full px-4 py-2.5 text-sm font-semibold rounded-lg text-white bg-blue-600 hover:bg-blue-700 transition-colors flex items-center justify-center gap-2"
179
+ >
180
+ <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
181
+ Search
182
+ </button>
183
+ </div>
184
+ </Show>
190
185
  </div>
191
186
  </Show>
192
187
 
193
188
  <style>{`
194
- @keyframes scratchpad-slide-down {
195
- from { opacity: 0; transform: translateY(-8px); }
196
- to { opacity: 1; transform: translateY(0); }
197
- }
189
+ @keyframes scratchpad-slide-down { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: translateY(0); } }
198
190
  `}</style>
199
191
  </div>
200
192
  )
@@ -212,152 +204,279 @@ const SectionRenderer: Component<{
212
204
  return (
213
205
  <div class="px-4 py-3">
214
206
  <h4 class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-2">{props.section.title}</h4>
215
-
216
207
  <Switch>
217
- <Match when={props.section.type === 'data'}>
218
- <DataSection content={props.section.content} />
219
- </Match>
220
- <Match when={props.section.type === 'filter'}>
221
- <FilterSection filters={props.filters} onFilterChange={props.onFilterChange} />
222
- </Match>
223
- <Match when={props.section.type === 'message'}>
224
- <p class="text-sm text-gray-700 dark:text-gray-300">{String(props.section.content)}</p>
225
- </Match>
226
- <Match when={props.section.type === 'action'}>
227
- <ActionSection content={props.section.content} onAction={props.onAction} />
228
- </Match>
229
- <Match when={props.section.type === 'steps'}>
230
- <StepsSection content={props.section.content} />
231
- </Match>
232
- <Match when={true}>
233
- <pre class="text-xs text-gray-500 dark:text-gray-400 overflow-auto">
234
- {JSON.stringify(props.section.content, null, 2)}
235
- </pre>
236
- </Match>
208
+ <Match when={props.section.type === 'data'}><DataSection content={props.section.content} /></Match>
209
+ <Match when={props.section.type === 'filter'}><InteractiveFilterSection content={props.section.content} filters={props.filters} onFilterChange={props.onFilterChange} /></Match>
210
+ <Match when={props.section.type === 'message'}><p class="text-sm text-gray-700 dark:text-gray-300">{String(props.section.content)}</p></Match>
211
+ <Match when={props.section.type === 'action'}><ActionSection content={props.section.content} onAction={props.onAction} /></Match>
212
+ <Match when={props.section.type === 'steps'}><EnrichedStepsSection content={props.section.content} onAction={props.onAction} onFilterChange={props.onFilterChange} /></Match>
213
+ <Match when={props.section.type === 'form'}><EmbeddedFormSection content={props.section.content} sectionId={props.section.id} onAction={props.onAction} /></Match>
214
+ <Match when={true}><pre class="text-xs text-gray-500 overflow-auto">{JSON.stringify(props.section.content, null, 2)}</pre></Match>
237
215
  </Switch>
238
216
  </div>
239
217
  )
240
218
  }
241
219
 
242
- // ─── Sub-components ──────────────────────────────────────────
220
+ // ─── Data Section ────────────────────────────────────────────
243
221
 
244
222
  const DataSection: Component<{ content: unknown }> = (props) => {
245
- const entries = () => {
246
- if (typeof props.content !== 'object' || !props.content) return []
247
- return Object.entries(props.content as Record<string, unknown>)
248
- }
249
-
223
+ const entries = () => typeof props.content === 'object' && props.content ? Object.entries(props.content as Record<string, unknown>) : []
250
224
  return (
251
225
  <div class="space-y-1">
252
- <For each={entries()}>
253
- {([key, value]) => (
254
- <div class="flex gap-2 text-sm">
255
- <span class="text-gray-500 dark:text-gray-400 font-mono text-xs min-w-[120px]">{key}:</span>
256
- <span class="text-gray-900 dark:text-white text-xs">
257
- {Array.isArray(value) ? value.join(', ') : String(value)}
258
- </span>
259
- </div>
260
- )}
261
- </For>
226
+ <For each={entries()}>{([k, v]) => <div class="flex gap-2 text-xs"><span class="text-gray-500 dark:text-gray-400 font-mono min-w-[120px]">{k}:</span><span class="text-gray-900 dark:text-white">{Array.isArray(v) ? v.join(', ') : String(v)}</span></div>}</For>
262
227
  </div>
263
228
  )
264
229
  }
265
230
 
266
- const FilterSection: Component<{
231
+ // ─── Interactive Filter Section (#4, #5) ─────────────────────
232
+
233
+ const InteractiveFilterSection: Component<{
234
+ content: unknown
267
235
  filters: Record<string, string | string[]>
268
236
  onFilterChange?: (filters: Record<string, string | string[]>) => void
269
237
  }> = (props) => {
238
+ const [editingKey, setEditingKey] = createSignal<string | null>(null)
239
+ const [editValue, setEditValue] = createSignal('')
240
+
241
+ // Content can be a filter definition or just use props.filters
242
+ const filterDefs = () => {
243
+ if (typeof props.content === 'object' && props.content) return props.content as Record<string, any>
244
+ return {}
245
+ }
246
+
247
+ const allKeys = () => {
248
+ const fromContent = Object.keys(filterDefs())
249
+ const fromFilters = Object.keys(props.filters || {})
250
+ return [...new Set([...fromContent, ...fromFilters])]
251
+ }
252
+
270
253
  const removeFilter = (key: string) => {
271
254
  const next = { ...props.filters }
272
255
  delete next[key]
273
256
  props.onFilterChange?.(next)
274
257
  }
275
258
 
276
- const entries = () => Object.entries(props.filters)
259
+ const setFilter = (key: string, value: string) => {
260
+ props.onFilterChange?.({ ...props.filters, [key]: value })
261
+ setEditingKey(null)
262
+ setEditValue('')
263
+ }
264
+
265
+ const getDef = (key: string) => filterDefs()[key] || {}
277
266
 
278
267
  return (
279
268
  <div class="flex flex-wrap gap-1.5">
280
- <For each={entries()}>
281
- {([key, value]) => (
282
- <span class="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-full">
283
- <span class="text-blue-500 dark:text-blue-400">{key}:</span>
284
- {Array.isArray(value) ? value.join(', ') : String(value)}
285
- <Show when={props.onFilterChange}>
286
- <button
287
- type="button"
288
- onClick={() => removeFilter(key)}
289
- class="ml-0.5 hover:text-blue-900 dark:hover:text-blue-100 transition-colors"
290
- aria-label={`Remove filter ${key}`}
291
- >
292
- &times;
293
- </button>
294
- </Show>
295
- </span>
296
- )}
269
+ <For each={allKeys()}>
270
+ {(key) => {
271
+ const def = () => getDef(key)
272
+ const value = () => props.filters[key]
273
+ const hasValue = () => value() !== undefined && value() !== ''
274
+
275
+ return (
276
+ <div class="relative">
277
+ <Show when={hasValue()} fallback={
278
+ <button type="button" onClick={() => { setEditingKey(key); setEditValue('') }}
279
+ class="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium border border-dashed border-gray-300 dark:border-gray-600 text-gray-500 dark:text-gray-400 rounded-full hover:border-blue-400 hover:text-blue-500 transition-colors">
280
+ + {def()?.label || key}
281
+ </button>
282
+ }>
283
+ <span class="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-full">
284
+ <button type="button" onClick={() => { setEditingKey(key); setEditValue(String(value() || '')) }} class="hover:underline">
285
+ <span class="text-blue-500 dark:text-blue-400">{def()?.label || key}:</span> {Array.isArray(value()) ? (value() as string[]).join(', ') : String(value())}
286
+ </button>
287
+ <Show when={props.onFilterChange}>
288
+ <button type="button" onClick={() => removeFilter(key)} class="ml-0.5 hover:text-blue-900 dark:hover:text-blue-100" aria-label={`Remove ${key}`}>&times;</button>
289
+ </Show>
290
+ </span>
291
+ </Show>
292
+
293
+ {/* Inline editor */}
294
+ <Show when={editingKey() === key}>
295
+ <div class="absolute z-50 mt-1 left-0 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg p-2 min-w-[200px]">
296
+ <Show when={def()?.options} fallback={
297
+ <form onSubmit={(e) => { e.preventDefault(); setFilter(key, editValue()) }} class="flex gap-1">
298
+ <input type="text" value={editValue()} onInput={(e) => setEditValue(e.currentTarget.value)} placeholder={def()?.placeholder || key} autofocus
299
+ class="flex-1 px-2 py-1 text-sm border border-gray-200 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:border-blue-400 outline-none" />
300
+ <button type="submit" class="px-2 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700">OK</button>
301
+ <button type="button" onClick={() => setEditingKey(null)} class="px-2 py-1 text-xs text-gray-500 hover:text-gray-700">Cancel</button>
302
+ </form>
303
+ }>
304
+ <div class="max-h-48 overflow-y-auto">
305
+ <For each={def().options as Array<{ value: string; label: string }>}>
306
+ {(opt) => (
307
+ <button type="button" onClick={() => setFilter(key, opt.value)}
308
+ class={`w-full text-left px-2 py-1.5 text-sm rounded hover:bg-blue-50 dark:hover:bg-blue-900/20 ${
309
+ String(value()) === opt.value ? 'text-blue-600 dark:text-blue-400 bg-blue-50/50 dark:bg-blue-900/10' : 'text-gray-900 dark:text-white'
310
+ }`}>
311
+ {opt.label}
312
+ <Show when={String(value()) === opt.value}><span class="ml-1">✓</span></Show>
313
+ </button>
314
+ )}
315
+ </For>
316
+ </div>
317
+ <button type="button" onClick={() => setEditingKey(null)} class="mt-1 w-full px-2 py-1 text-xs text-gray-500 hover:text-gray-700 text-center">Cancel</button>
318
+ </Show>
319
+ </div>
320
+ </Show>
321
+ </div>
322
+ )
323
+ }}
297
324
  </For>
298
- <Show when={entries().length === 0}>
299
- <p class="text-xs text-gray-400 italic">No filters applied</p>
325
+ <Show when={allKeys().length === 0}>
326
+ <p class="text-xs text-gray-400 italic">No filters</p>
300
327
  </Show>
301
328
  </div>
302
329
  )
303
330
  }
304
331
 
305
- const ActionSection: Component<{
332
+ // ─── Embedded Form Section (#7, #8) ──────────────────────────
333
+
334
+ const EmbeddedFormSection: Component<{
306
335
  content: unknown
336
+ sectionId: string
307
337
  onAction?: (action: string, data?: unknown) => void
308
338
  }> = (props) => {
309
- const actions = () => {
310
- if (Array.isArray(props.content)) return props.content as Array<{ label: string; value?: string; action?: string; variant?: string; icon?: string }>
311
- return []
339
+ const [formData, setFormData] = createSignal<Record<string, any>>({})
340
+ const [dynamicOptions, setDynamicOptions] = createSignal<Record<string, Array<{ label: string; value: string }>>>({})
341
+
342
+ const config = () => {
343
+ const c = props.content as any
344
+ return { fields: c?.fields || [], submitLabel: c?.submitLabel || 'Submit' }
345
+ }
346
+
347
+ const updateField = (name: string, value: any) => setFormData(prev => ({ ...prev, [name]: value }))
348
+
349
+ // depends_on reactive (#9)
350
+ createEffect(() => {
351
+ const data = formData()
352
+ for (const field of config().fields) {
353
+ const dep = field.depends_on || field.dependsOn
354
+ if (!dep) continue
355
+ const parentValue = data[dep.field]
356
+ if (!parentValue) continue
357
+ const url = (dep.options_endpoint || dep.apiUrl || '').replace('{value}', encodeURIComponent(parentValue))
358
+ if (!url) continue
359
+ const params = new URLSearchParams(dep.extraParams || dep.extra_params || {})
360
+ fetch(`${url}${url.includes('?') ? '&' : '?'}${params}`)
361
+ .then(r => r.json())
362
+ .then(items => {
363
+ const arr = Array.isArray(items) ? items : items.results || items.features || []
364
+ const lf = dep.label_field || dep.labelField || 'label'
365
+ const vf = dep.value_field || dep.valueField || 'value'
366
+ setDynamicOptions(prev => ({ ...prev, [field.name]: arr.map((i: any) => ({ label: i[lf] || String(i), value: String(i[vf] || i[lf] || i) })) }))
367
+ })
368
+ .catch(() => {})
369
+ }
370
+ })
371
+
372
+ const getField = (field: any): FormFieldParams => {
373
+ const dynOpts = dynamicOptions()[field.name]
374
+ return dynOpts ? { ...field, options: dynOpts } as FormFieldParams : field as FormFieldParams
375
+ }
376
+
377
+ const handleSubmit = (e: Event) => {
378
+ e.preventDefault()
379
+ props.onAction?.('submit_form', { sectionId: props.sectionId, values: formData() })
312
380
  }
313
381
 
314
382
  return (
315
- <div class="flex flex-wrap gap-2">
316
- <For each={actions()}>
317
- {(item) => (
318
- <button
319
- type="button"
320
- onClick={() => props.onAction?.(item.value || item.action || item.label, item)}
321
- class={`px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${
322
- item.variant === 'primary'
323
- ? 'bg-blue-600 text-white hover:bg-blue-700'
324
- : item.variant === 'danger'
325
- ? 'bg-red-600 text-white hover:bg-red-700'
326
- : 'border border-gray-200 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
327
- }`}
328
- >
329
- <Show when={item.icon}><span class="mr-1">{item.icon}</span></Show>
330
- {item.label}
331
- </button>
383
+ <form onSubmit={handleSubmit} class="flex flex-col gap-3">
384
+ <For each={config().fields}>
385
+ {(field) => (
386
+ <FormFieldRenderer
387
+ field={getField(field)}
388
+ value={formData()[field.name]}
389
+ onChange={(val) => updateField(field.name, val)}
390
+ formData={formData}
391
+ />
332
392
  )}
333
393
  </For>
334
- </div>
394
+ <div class="flex justify-end">
395
+ <button type="submit" class="px-4 py-2 text-sm font-medium rounded-lg text-white bg-blue-600 hover:bg-blue-700 transition-colors">
396
+ {config().submitLabel}
397
+ </button>
398
+ </div>
399
+ </form>
335
400
  )
336
401
  }
337
402
 
338
- const StepsSection: Component<{ content: unknown }> = (props) => {
339
- const steps = () => {
340
- if (Array.isArray(props.content)) return props.content as Array<{ label: string; status: 'done' | 'active' | 'pending' }>
341
- return []
403
+ // ─── Enriched Steps Section (#6) ─────────────────────────────
404
+
405
+ const EnrichedStepsSection: Component<{
406
+ content: unknown
407
+ onAction?: (action: string, data?: unknown) => void
408
+ onFilterChange?: (filters: Record<string, string | string[]>) => void
409
+ }> = (props) => {
410
+ const stepsData = () => {
411
+ const c = props.content as any
412
+ return { steps: c?.steps || [], currentStep: c?.currentStep ?? 0 }
342
413
  }
343
414
 
344
415
  return (
345
- <div class="flex items-center gap-1">
346
- <For each={steps()}>
347
- {(step, i) => (
348
- <>
349
- <Show when={i() > 0}>
350
- <div class={`w-6 h-px ${step.status === 'pending' ? 'bg-gray-300 dark:bg-gray-600' : 'bg-blue-400'}`} />
351
- </Show>
352
- <div class={`flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium ${
416
+ <div class="space-y-3">
417
+ <For each={stepsData().steps}>
418
+ {(step: any) => (
419
+ <div class={`rounded-lg ${step.status === 'active' ? 'bg-blue-50/50 dark:bg-blue-900/10 border border-blue-200 dark:border-blue-800 p-3' : 'px-1'}`}>
420
+ <div class={`flex items-center gap-2 text-sm font-medium ${
353
421
  step.status === 'done' ? 'text-green-600 dark:text-green-400'
354
- : step.status === 'active' ? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20'
422
+ : step.status === 'active' ? 'text-blue-600 dark:text-blue-400'
355
423
  : 'text-gray-400'
356
424
  }`}>
357
- {step.status === 'done' ? '' : step.status === 'active' ? '●' : '○'}
425
+ <span>{step.status === 'done' ? '' : step.status === 'active' ? '●' : '○'}</span>
358
426
  {step.label}
427
+ <Show when={step.description && step.status === 'active'}>
428
+ <span class="text-xs font-normal text-gray-500 dark:text-gray-400">— {step.description}</span>
429
+ </Show>
359
430
  </div>
360
- </>
431
+
432
+ {/* Embedded content for active step */}
433
+ <Show when={step.status === 'active' && step.content}>
434
+ <div class="mt-2 ml-6">
435
+ <SectionRenderer
436
+ section={step.content}
437
+ filters={{}}
438
+ onFilterChange={props.onFilterChange}
439
+ onAction={props.onAction}
440
+ />
441
+ </div>
442
+ </Show>
443
+ </div>
444
+ )}
445
+ </For>
446
+
447
+ {/* Next button */}
448
+ <Show when={stepsData().steps.some((s: any) => s.status === 'active')}>
449
+ <div class="flex justify-end">
450
+ <button type="button" onClick={() => props.onAction?.('next_step', { step: stepsData().currentStep })}
451
+ class="px-3 py-1.5 text-sm font-medium text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg transition-colors flex items-center gap-1">
452
+ Next <svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>
453
+ </button>
454
+ </div>
455
+ </Show>
456
+ </div>
457
+ )
458
+ }
459
+
460
+ // ─── Action Section ──────────────────────────────────────────
461
+
462
+ const ActionSection: Component<{
463
+ content: unknown
464
+ onAction?: (action: string, data?: unknown) => void
465
+ }> = (props) => {
466
+ const actions = () => Array.isArray(props.content) ? props.content as Array<{ label: string; value?: string; action?: string; variant?: string; icon?: string }> : []
467
+ return (
468
+ <div class="flex flex-wrap gap-2">
469
+ <For each={actions()}>
470
+ {(item) => (
471
+ <button type="button" onClick={() => props.onAction?.(item.value || item.action || item.label, item)}
472
+ class={`px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${
473
+ item.variant === 'primary' ? 'bg-blue-600 text-white hover:bg-blue-700'
474
+ : item.variant === 'danger' ? 'bg-red-600 text-white hover:bg-red-700'
475
+ : 'border border-gray-200 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
476
+ }`}>
477
+ <Show when={item.icon}><span class="mr-1">{item.icon}</span></Show>
478
+ {item.label}
479
+ </button>
361
480
  )}
362
481
  </For>
363
482
  </div>
@@ -60,6 +60,7 @@ export interface ChatEvents {
60
60
 
61
61
  // --- Scratchpad (HITL shared workspace) ---
62
62
  onScratchpad: (event: ChatEventBase & { scratchpad: ScratchpadEvent }) => void
63
+ onScratchpadPreview: (event: ChatEventBase & { id: string; preview: ScratchpadState['preview'] }) => void
63
64
 
64
65
  // --- Fallback ---
65
66
  onCustomEvent: (type: string, event: ChatEventBase & { data: unknown }) => void
@@ -100,6 +101,10 @@ export interface ChatCommands {
100
101
  /** Show suggestion chips */
101
102
  showSuggestions: (items: SuggestionItem[]) => void
102
103
 
104
+ // --- Scratchpad ---
105
+ /** Send scratchpad filter/form changes to the agent */
106
+ updateScratchpad: (id: string, update: { filters?: Record<string, string | string[]>; formData?: Record<string, unknown> }) => void
107
+
103
108
  // --- Configuration ---
104
109
  /** Toggle a connector on/off */
105
110
  toggleConnector: (connectorId: string, enabled: boolean) => void
@@ -335,6 +340,10 @@ export interface ScratchpadState {
335
340
  /** Agent messages (explanations, questions) */
336
341
  agentMessages: Array<{ text: string; type: 'info' | 'question' | 'warning' }>
337
342
  status: 'loading' | 'ready' | 'waiting_human' | 'processing' | 'complete'
343
+ /** Endpoint for auto-refresh preview when filters change */
344
+ previewEndpoint?: string
345
+ /** Debounce delay for preview refresh (ms, default 500) */
346
+ previewDebounce?: number
338
347
  }
339
348
 
340
349
  export interface ScratchpadSection {