@seed-ship/mcp-ui-solid 2.8.1 → 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,21 +1,25 @@
1
1
  /**
2
- * ScratchpadPanel — HITL shared workspace between agent and human
3
- * v2.7.0: Renders scratchpad sections, editable filters, preview, agent messages
2
+ * ScratchpadPanel v3 Full HITL/AITL workspace
3
+ * v2.9.0: Interactive filters, form sections, stepper, preview auto-refresh
4
4
  *
5
- * @experimental — This component may change without major bump until v2.5.0 stabilization.
5
+ * @experimental
6
6
  */
7
7
 
8
- import { Component, Show, For, Switch, Match, createSignal } from 'solid-js'
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
18
+ onClose?: () => void
19
+ closable?: boolean
20
+ autoCloseDelay?: number
21
+ collapsible?: boolean
22
+ maxHeight?: string
19
23
  }
20
24
 
21
25
  const STATUS_BADGES: Record<ScratchpadState['status'], { label: string; class: string }> = {
@@ -23,99 +27,167 @@ const STATUS_BADGES: Record<ScratchpadState['status'], { label: string; class: s
23
27
  ready: { label: 'Ready', class: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' },
24
28
  waiting_human: { label: 'Your turn', class: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400 animate-pulse' },
25
29
  processing: { label: 'Processing...', class: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' },
26
- complete: { label: 'Complete', class: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400' },
30
+ complete: { label: 'Complete', class: 'bg-green-100 text-green-600 dark:bg-green-900/30 dark:text-green-400' },
27
31
  }
28
32
 
29
- /**
30
- * @experimental
31
- */
32
33
  export const ScratchpadPanel: Component<ScratchpadPanelProps> = (props) => {
34
+ const [collapsed, setCollapsed] = createSignal(false)
35
+ const [localPreview, setLocalPreview] = createSignal<ScratchpadState['preview']>(undefined)
36
+ let previewTimer: ReturnType<typeof setTimeout> | null = null
33
37
  const badge = () => STATUS_BADGES[props.state.status] || STATUS_BADGES.loading
38
+ const isClosable = () => props.closable !== false
39
+ const isCollapsible = () => props.collapsible !== false
40
+ const preview = () => localPreview() || props.state.preview
41
+ const hasFilters = () => Object.keys(props.state.filters || {}).length > 0
42
+
43
+ // Auto-close on complete
44
+ createEffect(() => {
45
+ if (props.state.status === 'complete' && props.autoCloseDelay) {
46
+ const timer = setTimeout(() => props.onClose?.(), props.autoCloseDelay)
47
+ onCleanup(() => clearTimeout(timer))
48
+ }
49
+ })
50
+
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) })
34
73
 
35
74
  return (
36
- <div class="w-full bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-lg overflow-visible">
75
+ <div
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
+ }`}
81
+ style={{ animation: 'scratchpad-slide-down 0.2s ease-out' }}
82
+ >
37
83
  {/* Header */}
38
- <div class="flex items-center justify-between px-4 py-3 border-b border-gray-100 dark:border-gray-700">
84
+ <div
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' : ''}`}
86
+ onClick={() => isCollapsible() && setCollapsed(!collapsed())}
87
+ >
39
88
  <div class="flex items-center gap-2">
40
89
  <span class="text-base">&#128221;</span>
41
90
  <h3 class="text-sm font-semibold text-gray-900 dark:text-white">{props.state.title}</h3>
91
+ <Show when={isCollapsible()}>
92
+ <svg class={`w-3.5 h-3.5 text-gray-400 transition-transform ${collapsed() ? '-rotate-90' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
93
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
94
+ </svg>
95
+ </Show>
96
+ </div>
97
+ <div class="flex items-center gap-2">
98
+ <span class={`px-2 py-0.5 text-xs font-medium rounded-full ${badge().class}`}>{badge().label}</span>
99
+ <Show when={isClosable() && props.onClose}>
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>
102
+ </button>
103
+ </Show>
42
104
  </div>
43
- <span class={`px-2 py-0.5 text-xs font-medium rounded-full ${badge().class}`}>
44
- {badge().label}
45
- </span>
46
105
  </div>
47
106
 
48
- {/* Sections */}
49
- <div class="divide-y divide-gray-100 dark:divide-gray-700">
50
- <For each={props.state.sections}>
51
- {(section) => (
52
- <SectionRenderer
53
- section={section}
54
- filters={props.state.filters}
55
- onFilterChange={props.onFilterChange}
56
- onAction={props.onAction}
57
- onSectionEdit={props.onSectionEdit}
58
- />
59
- )}
60
- </For>
61
- </div>
107
+ {/* Body */}
108
+ <Show when={!collapsed()}>
109
+ <div style={{ "max-height": props.maxHeight || "500px", "overflow-y": "auto" }}>
110
+ {/* Sections */}
111
+ <div class="divide-y divide-gray-100 dark:divide-gray-700">
112
+ <For each={props.state.sections}>
113
+ {(section) => (
114
+ <SectionRenderer
115
+ section={section}
116
+ filters={props.state.filters}
117
+ onFilterChange={props.onFilterChange}
118
+ onAction={props.onAction}
119
+ onSectionEdit={props.onSectionEdit}
120
+ />
121
+ )}
122
+ </For>
123
+ </div>
62
124
 
63
- {/* Agent messages */}
64
- <Show when={props.state.agentMessages.length > 0}>
65
- <div class="px-4 py-3 border-t border-gray-100 dark:border-gray-700 space-y-2">
66
- <For each={props.state.agentMessages}>
67
- {(msg) => (
68
- <div class={`flex items-start gap-2 text-sm ${
69
- msg.type === 'warning' ? 'text-amber-600 dark:text-amber-400'
70
- : msg.type === 'question' ? 'text-blue-600 dark:text-blue-400'
71
- : 'text-gray-600 dark:text-gray-400'
72
- }`}>
73
- <span class="flex-shrink-0 mt-0.5">
74
- {msg.type === 'warning' ? '⚠️' : msg.type === 'question' ? '💬' : 'ℹ️'}
75
- </span>
76
- <p>{msg.text}</p>
77
- </div>
78
- )}
79
- </For>
80
- </div>
81
- </Show>
125
+ {/* Agent messages */}
126
+ <Show when={props.state.agentMessages.length > 0}>
127
+ <div class="px-4 py-3 border-t border-gray-100 dark:border-gray-700 space-y-2">
128
+ <For each={props.state.agentMessages}>
129
+ {(msg) => (
130
+ <div class={`flex items-start gap-2 text-sm rounded-lg px-3 py-2 ${
131
+ msg.type === 'warning' ? 'bg-amber-50 dark:bg-amber-900/10 text-amber-700 dark:text-amber-400'
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'
133
+ : 'bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-400'
134
+ }`}>
135
+ <span class="flex-shrink-0 mt-0.5">{msg.type === 'warning' ? '⚠️' : msg.type === 'question' ? '❓' : 'ℹ️'}</span>
136
+ <p>{msg.text}</p>
137
+ </div>
138
+ )}
139
+ </For>
140
+ </div>
141
+ </Show>
82
142
 
83
- {/* Preview */}
84
- <Show when={props.state.preview}>
85
- <div class="px-4 py-3 border-t border-gray-100 dark:border-gray-700">
86
- <div class="flex items-center gap-2 mb-2">
87
- <span class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Preview</span>
88
- <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">
89
- {props.state.preview!.count.toLocaleString()}
90
- </span>
91
- </div>
92
- <p class="text-sm text-gray-700 dark:text-gray-300">{props.state.preview!.summary}</p>
93
- <Show when={props.state.preview!.rows && props.state.preview!.rows!.length > 0}>
94
- <div class="mt-2 overflow-x-auto">
95
- <table class="min-w-full text-xs">
96
- <thead>
97
- <tr>
98
- <For each={Object.keys(props.state.preview!.rows![0])}>
99
- {(key) => <th class="px-2 py-1 text-left font-medium text-gray-500 dark:text-gray-400">{key}</th>}
100
- </For>
101
- </tr>
102
- </thead>
103
- <tbody>
104
- <For each={props.state.preview!.rows!.slice(0, 5)}>
105
- {(row) => (
106
- <tr class="border-t border-gray-100 dark:border-gray-700">
107
- <For each={Object.values(row)}>
108
- {(val) => <td class="px-2 py-1 text-gray-700 dark:text-gray-300">{String(val)}</td>}
109
- </For>
110
- </tr>
111
- )}
112
- </For>
113
- </tbody>
114
- </table>
143
+ {/* Preview */}
144
+ <Show when={preview()}>
145
+ <div class="px-4 py-3 border-t border-gray-100 dark:border-gray-700">
146
+ <Show when={preview()!.count === 0} fallback={
147
+ <>
148
+ <div class="flex items-center gap-2 mb-2">
149
+ <span class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Preview</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>
151
+ </div>
152
+ <p class="text-sm text-gray-700 dark:text-gray-300">{preview()!.summary}</p>
153
+ <Show when={preview()!.rows && preview()!.rows!.length > 0}>
154
+ <div class="mt-2 overflow-x-auto">
155
+ <table class="min-w-full text-xs">
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>
158
+ </table>
159
+ </div>
160
+ </Show>
161
+ </>
162
+ }>
163
+ <div class="flex flex-col items-center gap-2 py-4 text-center">
164
+ <span class="text-2xl">&#128269;</span>
165
+ <p class="text-sm text-gray-500 dark:text-gray-400">No results for these filters</p>
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>
167
+ </div>
168
+ </Show>
169
+ </div>
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>
115
183
  </div>
116
184
  </Show>
117
185
  </div>
118
186
  </Show>
187
+
188
+ <style>{`
189
+ @keyframes scratchpad-slide-down { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: translateY(0); } }
190
+ `}</style>
119
191
  </div>
120
192
  )
121
193
  }
@@ -131,166 +203,280 @@ const SectionRenderer: Component<{
131
203
  }> = (props) => {
132
204
  return (
133
205
  <div class="px-4 py-3">
134
- <div class="flex items-center gap-2 mb-2">
135
- <h4 class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">{props.section.title}</h4>
136
- <Show when={props.section.editable}>
137
- <span class="text-[10px] text-blue-500 dark:text-blue-400">(editable)</span>
138
- </Show>
139
- </div>
140
-
206
+ <h4 class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-2">{props.section.title}</h4>
141
207
  <Switch>
142
- {/* Data section key-value or compact table */}
143
- <Match when={props.section.type === 'data'}>
144
- <DataSection content={props.section.content} />
145
- </Match>
146
-
147
- {/* Filter section editable chips */}
148
- <Match when={props.section.type === 'filter'}>
149
- <FilterSection
150
- filters={props.filters}
151
- onFilterChange={props.onFilterChange}
152
- />
153
- </Match>
154
-
155
- {/* Message section */}
156
- <Match when={props.section.type === 'message'}>
157
- <p class="text-sm text-gray-700 dark:text-gray-300">{String(props.section.content)}</p>
158
- </Match>
159
-
160
- {/* Action section — buttons */}
161
- <Match when={props.section.type === 'action'}>
162
- <ActionSection content={props.section.content} onAction={props.onAction} />
163
- </Match>
164
-
165
- {/* Steps section */}
166
- <Match when={props.section.type === 'steps'}>
167
- <StepsSection content={props.section.content} />
168
- </Match>
169
-
170
- {/* Fallback */}
171
- <Match when={true}>
172
- <pre class="text-xs text-gray-500 dark:text-gray-400 overflow-auto">
173
- {JSON.stringify(props.section.content, null, 2)}
174
- </pre>
175
- </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>
176
215
  </Switch>
177
216
  </div>
178
217
  )
179
218
  }
180
219
 
181
- // ─── Sub-components ──────────────────────────────────────────
220
+ // ─── Data Section ────────────────────────────────────────────
182
221
 
183
222
  const DataSection: Component<{ content: unknown }> = (props) => {
184
- const entries = () => {
185
- if (typeof props.content !== 'object' || !props.content) return []
186
- return Object.entries(props.content as Record<string, unknown>)
187
- }
188
-
223
+ const entries = () => typeof props.content === 'object' && props.content ? Object.entries(props.content as Record<string, unknown>) : []
189
224
  return (
190
225
  <div class="space-y-1">
191
- <For each={entries()}>
192
- {([key, value]) => (
193
- <div class="flex gap-2 text-sm">
194
- <span class="text-gray-500 dark:text-gray-400 font-mono text-xs min-w-[120px]">{key}:</span>
195
- <span class="text-gray-900 dark:text-white text-xs">
196
- {Array.isArray(value) ? value.join(', ') : String(value)}
197
- </span>
198
- </div>
199
- )}
200
- </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>
201
227
  </div>
202
228
  )
203
229
  }
204
230
 
205
- const FilterSection: Component<{
231
+ // ─── Interactive Filter Section (#4, #5) ─────────────────────
232
+
233
+ const InteractiveFilterSection: Component<{
234
+ content: unknown
206
235
  filters: Record<string, string | string[]>
207
236
  onFilterChange?: (filters: Record<string, string | string[]>) => void
208
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
+
209
253
  const removeFilter = (key: string) => {
210
254
  const next = { ...props.filters }
211
255
  delete next[key]
212
256
  props.onFilterChange?.(next)
213
257
  }
214
258
 
215
- 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] || {}
216
266
 
217
267
  return (
218
268
  <div class="flex flex-wrap gap-1.5">
219
- <For each={entries()}>
220
- {([key, value]) => (
221
- <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">
222
- <span class="text-blue-500 dark:text-blue-400">{key}:</span>
223
- {Array.isArray(value) ? value.join(', ') : String(value)}
224
- <button
225
- type="button"
226
- onClick={() => removeFilter(key)}
227
- class="ml-0.5 hover:text-blue-900 dark:hover:text-blue-100"
228
- aria-label={`Remove filter ${key}`}
229
- >
230
- &times;
231
- </button>
232
- </span>
233
- )}
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
+ }}
234
324
  </For>
325
+ <Show when={allKeys().length === 0}>
326
+ <p class="text-xs text-gray-400 italic">No filters</p>
327
+ </Show>
235
328
  </div>
236
329
  )
237
330
  }
238
331
 
239
- const ActionSection: Component<{
332
+ // ─── Embedded Form Section (#7, #8) ──────────────────────────
333
+
334
+ const EmbeddedFormSection: Component<{
240
335
  content: unknown
336
+ sectionId: string
241
337
  onAction?: (action: string, data?: unknown) => void
242
338
  }> = (props) => {
243
- const actions = () => {
244
- if (Array.isArray(props.content)) return props.content as Array<{ label: string; action: string; variant?: string }>
245
- 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() })
246
380
  }
247
381
 
248
382
  return (
249
- <div class="flex flex-wrap gap-2">
250
- <For each={actions()}>
251
- {(item) => (
252
- <button
253
- type="button"
254
- onClick={() => props.onAction?.(item.action)}
255
- class={`px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${
256
- item.variant === 'primary'
257
- ? 'bg-blue-600 text-white hover:bg-blue-700'
258
- : item.variant === 'danger'
259
- ? 'bg-red-600 text-white hover:bg-red-700'
260
- : 'border border-gray-200 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
261
- }`}
262
- >
263
- {item.label}
264
- </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
+ />
265
392
  )}
266
393
  </For>
267
- </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>
268
400
  )
269
401
  }
270
402
 
271
- const StepsSection: Component<{ content: unknown }> = (props) => {
272
- const steps = () => {
273
- if (Array.isArray(props.content)) return props.content as Array<{ label: string; status: 'done' | 'active' | 'pending' }>
274
- 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 }
275
413
  }
276
414
 
277
415
  return (
278
- <div class="flex items-center gap-1">
279
- <For each={steps()}>
280
- {(step, i) => (
281
- <>
282
- <Show when={i() > 0}>
283
- <div class={`w-6 h-px ${step.status === 'pending' ? 'bg-gray-300 dark:bg-gray-600' : 'bg-blue-400'}`} />
284
- </Show>
285
- <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 ${
286
421
  step.status === 'done' ? 'text-green-600 dark:text-green-400'
287
- : 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'
288
423
  : 'text-gray-400'
289
424
  }`}>
290
- {step.status === 'done' ? '' : step.status === 'active' ? '●' : '○'}
425
+ <span>{step.status === 'done' ? '' : step.status === 'active' ? '●' : '○'}</span>
291
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>
292
430
  </div>
293
- </>
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>
294
480
  )}
295
481
  </For>
296
482
  </div>