@seed-ship/mcp-ui-solid 2.8.3 → 2.10.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,42 @@ 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" }}>
110
+ {/* Turn history header */}
111
+ <Show when={props.state.turnHistory && props.state.turnHistory.length > 0}>
112
+ <div class="px-4 py-2 border-b border-gray-100 dark:border-gray-700 flex flex-wrap items-center gap-1.5">
113
+ <For each={props.state.turnHistory}>
114
+ {(turn, i) => (
115
+ <>
116
+ <Show when={i() > 0}>
117
+ <svg class="w-3 h-3 text-gray-300 dark:text-gray-600 flex-shrink-0" 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>
118
+ </Show>
119
+ <span class={`text-xs font-medium px-2 py-0.5 rounded-full ${
120
+ turn.status === 'done' ? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400'
121
+ : turn.status === 'active' ? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400'
122
+ : turn.status === 'skipped' ? 'bg-gray-100 dark:bg-gray-700 text-gray-400 line-through'
123
+ : 'bg-gray-100 dark:bg-gray-700 text-gray-400'
124
+ }`}>
125
+ {turn.status === 'done' ? '✅' : turn.status === 'active' ? '●' : '○'} {turn.label}
126
+ <Show when={turn.summary}><span class="ml-1 font-normal opacity-75">— {turn.summary}</span></Show>
127
+ </span>
128
+ </>
129
+ )}
130
+ </For>
131
+ </div>
132
+ </Show>
133
+
100
134
  {/* Sections */}
101
135
  <div class="divide-y divide-gray-100 dark:divide-gray-700">
102
136
  <For each={props.state.sections}>
@@ -122,9 +156,7 @@ export const ScratchpadPanel: Component<ScratchpadPanelProps> = (props) => {
122
156
  : 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
157
  : 'bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-400'
124
158
  }`}>
125
- <span class="flex-shrink-0 mt-0.5">
126
- {msg.type === 'warning' ? '⚠️' : msg.type === 'question' ? '❓' : 'ℹ️'}
127
- </span>
159
+ <span class="flex-shrink-0 mt-0.5">{msg.type === 'warning' ? '⚠️' : msg.type === 'question' ? '❓' : 'ℹ️'}</span>
128
160
  <p>{msg.text}</p>
129
161
  </div>
130
162
  )}
@@ -133,68 +165,52 @@ export const ScratchpadPanel: Component<ScratchpadPanelProps> = (props) => {
133
165
  </Show>
134
166
 
135
167
  {/* Preview */}
136
- <Show when={props.state.preview}>
168
+ <Show when={preview()}>
137
169
  <div class="px-4 py-3 border-t border-gray-100 dark:border-gray-700">
138
- <Show when={props.state.preview!.count === 0} fallback={
170
+ <Show when={preview()!.count === 0} fallback={
139
171
  <>
140
172
  <div class="flex items-center gap-2 mb-2">
141
173
  <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>
174
+ <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
175
  </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}>
176
+ <p class="text-sm text-gray-700 dark:text-gray-300">{preview()!.summary}</p>
177
+ <Show when={preview()!.rows && preview()!.rows!.length > 0}>
148
178
  <div class="mt-2 overflow-x-auto">
149
179
  <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>
180
+ <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>
181
+ <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
182
  </table>
169
183
  </div>
170
184
  </Show>
171
185
  </>
172
186
  }>
173
- {/* Empty state */}
174
187
  <div class="flex flex-col items-center gap-2 py-4 text-center">
175
188
  <span class="text-2xl">&#128269;</span>
176
189
  <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>
190
+ <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
191
  </div>
187
192
  </Show>
188
193
  </div>
189
194
  </Show>
195
+
196
+ {/* Search button when waiting_human */}
197
+ <Show when={props.state.status === 'waiting_human' && hasFilters()}>
198
+ <div class="px-4 py-3 border-t border-gray-100 dark:border-gray-700">
199
+ <button
200
+ type="button"
201
+ onClick={() => props.onAction?.('search', { filters: props.state.filters })}
202
+ 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"
203
+ >
204
+ <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>
205
+ Search
206
+ </button>
207
+ </div>
208
+ </Show>
190
209
  </div>
191
210
  </Show>
192
211
 
193
212
  <style>{`
194
- @keyframes scratchpad-slide-down {
195
- from { opacity: 0; transform: translateY(-8px); }
196
- to { opacity: 1; transform: translateY(0); }
197
- }
213
+ @keyframes scratchpad-slide-down { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: translateY(0); } }
198
214
  `}</style>
199
215
  </div>
200
216
  )
@@ -212,120 +228,279 @@ const SectionRenderer: Component<{
212
228
  return (
213
229
  <div class="px-4 py-3">
214
230
  <h4 class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-2">{props.section.title}</h4>
215
-
216
231
  <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>
232
+ <Match when={props.section.type === 'data'}><DataSection content={props.section.content} /></Match>
233
+ <Match when={props.section.type === 'filter'}><InteractiveFilterSection content={props.section.content} filters={props.filters} onFilterChange={props.onFilterChange} /></Match>
234
+ <Match when={props.section.type === 'message'}><p class="text-sm text-gray-700 dark:text-gray-300">{String(props.section.content)}</p></Match>
235
+ <Match when={props.section.type === 'action'}><ActionSection content={props.section.content} onAction={props.onAction} /></Match>
236
+ <Match when={props.section.type === 'steps'}><EnrichedStepsSection content={props.section.content} onAction={props.onAction} onFilterChange={props.onFilterChange} /></Match>
237
+ <Match when={props.section.type === 'form'}><EmbeddedFormSection content={props.section.content} sectionId={props.section.id} onAction={props.onAction} /></Match>
238
+ <Match when={props.section.type === 'understanding'}><UnderstandingSection content={props.section.content} /></Match>
239
+ <Match when={props.section.type === 'feedback'}><FeedbackSection content={props.section.content} onAction={props.onAction} /></Match>
240
+ <Match when={props.section.type === 'prompt'}><PromptSection content={props.section.content} onAction={props.onAction} /></Match>
241
+ <Match when={true}><pre class="text-xs text-gray-500 overflow-auto">{JSON.stringify(props.section.content, null, 2)}</pre></Match>
237
242
  </Switch>
238
243
  </div>
239
244
  )
240
245
  }
241
246
 
242
- // ─── Sub-components ──────────────────────────────────────────
247
+ // ─── Data Section ────────────────────────────────────────────
243
248
 
244
249
  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
-
250
+ const entries = () => typeof props.content === 'object' && props.content ? Object.entries(props.content as Record<string, unknown>) : []
250
251
  return (
251
252
  <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>
253
+ <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
254
  </div>
263
255
  )
264
256
  }
265
257
 
266
- const FilterSection: Component<{
258
+ // ─── Interactive Filter Section (#4, #5) ─────────────────────
259
+
260
+ const InteractiveFilterSection: Component<{
261
+ content: unknown
267
262
  filters: Record<string, string | string[]>
268
263
  onFilterChange?: (filters: Record<string, string | string[]>) => void
269
264
  }> = (props) => {
265
+ const [editingKey, setEditingKey] = createSignal<string | null>(null)
266
+ const [editValue, setEditValue] = createSignal('')
267
+
268
+ // Content can be a filter definition or just use props.filters
269
+ const filterDefs = () => {
270
+ if (typeof props.content === 'object' && props.content) return props.content as Record<string, any>
271
+ return {}
272
+ }
273
+
274
+ const allKeys = () => {
275
+ const fromContent = Object.keys(filterDefs())
276
+ const fromFilters = Object.keys(props.filters || {})
277
+ return [...new Set([...fromContent, ...fromFilters])]
278
+ }
279
+
270
280
  const removeFilter = (key: string) => {
271
281
  const next = { ...props.filters }
272
282
  delete next[key]
273
283
  props.onFilterChange?.(next)
274
284
  }
275
285
 
276
- const entries = () => Object.entries(props.filters)
286
+ const setFilter = (key: string, value: string) => {
287
+ props.onFilterChange?.({ ...props.filters, [key]: value })
288
+ setEditingKey(null)
289
+ setEditValue('')
290
+ }
291
+
292
+ const getDef = (key: string) => filterDefs()[key] || {}
277
293
 
278
294
  return (
279
295
  <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>
296
+ <For each={allKeys()}>
297
+ {(key) => {
298
+ const def = () => getDef(key)
299
+ const value = () => props.filters[key]
300
+ const hasValue = () => value() !== undefined && value() !== ''
301
+
302
+ return (
303
+ <div class="relative">
304
+ <Show when={hasValue()} fallback={
305
+ <button type="button" onClick={() => { setEditingKey(key); setEditValue('') }}
306
+ 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">
307
+ + {def()?.label || key}
308
+ </button>
309
+ }>
310
+ <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">
311
+ <button type="button" onClick={() => { setEditingKey(key); setEditValue(String(value() || '')) }} class="hover:underline">
312
+ <span class="text-blue-500 dark:text-blue-400">{def()?.label || key}:</span> {Array.isArray(value()) ? (value() as string[]).join(', ') : String(value())}
313
+ </button>
314
+ <Show when={props.onFilterChange}>
315
+ <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>
316
+ </Show>
317
+ </span>
318
+ </Show>
319
+
320
+ {/* Inline editor */}
321
+ <Show when={editingKey() === key}>
322
+ <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]">
323
+ <Show when={def()?.options} fallback={
324
+ <form onSubmit={(e) => { e.preventDefault(); setFilter(key, editValue()) }} class="flex gap-1">
325
+ <input type="text" value={editValue()} onInput={(e) => setEditValue(e.currentTarget.value)} placeholder={def()?.placeholder || key} autofocus
326
+ 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" />
327
+ <button type="submit" class="px-2 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700">OK</button>
328
+ <button type="button" onClick={() => setEditingKey(null)} class="px-2 py-1 text-xs text-gray-500 hover:text-gray-700">Cancel</button>
329
+ </form>
330
+ }>
331
+ <div class="max-h-48 overflow-y-auto">
332
+ <For each={def().options as Array<{ value: string; label: string }>}>
333
+ {(opt) => (
334
+ <button type="button" onClick={() => setFilter(key, opt.value)}
335
+ class={`w-full text-left px-2 py-1.5 text-sm rounded hover:bg-blue-50 dark:hover:bg-blue-900/20 ${
336
+ 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'
337
+ }`}>
338
+ {opt.label}
339
+ <Show when={String(value()) === opt.value}><span class="ml-1">✓</span></Show>
340
+ </button>
341
+ )}
342
+ </For>
343
+ </div>
344
+ <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>
345
+ </Show>
346
+ </div>
347
+ </Show>
348
+ </div>
349
+ )
350
+ }}
351
+ </For>
352
+ <Show when={allKeys().length === 0}>
353
+ <p class="text-xs text-gray-400 italic">No filters</p>
354
+ </Show>
355
+ </div>
356
+ )
357
+ }
358
+
359
+ // ─── Embedded Form Section (#7, #8) ──────────────────────────
360
+
361
+ const EmbeddedFormSection: Component<{
362
+ content: unknown
363
+ sectionId: string
364
+ onAction?: (action: string, data?: unknown) => void
365
+ }> = (props) => {
366
+ const [formData, setFormData] = createSignal<Record<string, any>>({})
367
+ const [dynamicOptions, setDynamicOptions] = createSignal<Record<string, Array<{ label: string; value: string }>>>({})
368
+
369
+ const config = () => {
370
+ const c = props.content as any
371
+ return { fields: c?.fields || [], submitLabel: c?.submitLabel || 'Submit' }
372
+ }
373
+
374
+ const updateField = (name: string, value: any) => setFormData(prev => ({ ...prev, [name]: value }))
375
+
376
+ // depends_on reactive (#9)
377
+ createEffect(() => {
378
+ const data = formData()
379
+ for (const field of config().fields) {
380
+ const dep = field.depends_on || field.dependsOn
381
+ if (!dep) continue
382
+ const parentValue = data[dep.field]
383
+ if (!parentValue) continue
384
+ const url = (dep.options_endpoint || dep.apiUrl || '').replace('{value}', encodeURIComponent(parentValue))
385
+ if (!url) continue
386
+ const params = new URLSearchParams(dep.extraParams || dep.extra_params || {})
387
+ fetch(`${url}${url.includes('?') ? '&' : '?'}${params}`)
388
+ .then(r => r.json())
389
+ .then(items => {
390
+ const arr = Array.isArray(items) ? items : items.results || items.features || []
391
+ const lf = dep.label_field || dep.labelField || 'label'
392
+ const vf = dep.value_field || dep.valueField || 'value'
393
+ setDynamicOptions(prev => ({ ...prev, [field.name]: arr.map((i: any) => ({ label: i[lf] || String(i), value: String(i[vf] || i[lf] || i) })) }))
394
+ })
395
+ .catch(() => {})
396
+ }
397
+ })
398
+
399
+ const getField = (field: any): FormFieldParams => {
400
+ const dynOpts = dynamicOptions()[field.name]
401
+ return dynOpts ? { ...field, options: dynOpts } as FormFieldParams : field as FormFieldParams
402
+ }
403
+
404
+ const handleSubmit = (e: Event) => {
405
+ e.preventDefault()
406
+ props.onAction?.('submit_form', { sectionId: props.sectionId, values: formData() })
407
+ }
408
+
409
+ return (
410
+ <form onSubmit={handleSubmit} class="flex flex-col gap-3">
411
+ <For each={config().fields}>
412
+ {(field) => (
413
+ <FormFieldRenderer
414
+ field={getField(field)}
415
+ value={formData()[field.name]}
416
+ onChange={(val) => updateField(field.name, val)}
417
+ formData={formData}
418
+ />
419
+ )}
420
+ </For>
421
+ <div class="flex justify-end">
422
+ <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">
423
+ {config().submitLabel}
424
+ </button>
425
+ </div>
426
+ </form>
427
+ )
428
+ }
429
+
430
+ // ─── Enriched Steps Section (#6) ─────────────────────────────
431
+
432
+ const EnrichedStepsSection: Component<{
433
+ content: unknown
434
+ onAction?: (action: string, data?: unknown) => void
435
+ onFilterChange?: (filters: Record<string, string | string[]>) => void
436
+ }> = (props) => {
437
+ const stepsData = () => {
438
+ const c = props.content as any
439
+ return { steps: c?.steps || [], currentStep: c?.currentStep ?? 0 }
440
+ }
441
+
442
+ return (
443
+ <div class="space-y-3">
444
+ <For each={stepsData().steps}>
445
+ {(step: any) => (
446
+ <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'}`}>
447
+ <div class={`flex items-center gap-2 text-sm font-medium ${
448
+ step.status === 'done' ? 'text-green-600 dark:text-green-400'
449
+ : step.status === 'active' ? 'text-blue-600 dark:text-blue-400'
450
+ : 'text-gray-400'
451
+ }`}>
452
+ <span>{step.status === 'done' ? '✅' : step.status === 'active' ? '●' : '○'}</span>
453
+ {step.label}
454
+ <Show when={step.description && step.status === 'active'}>
455
+ <span class="text-xs font-normal text-gray-500 dark:text-gray-400">— {step.description}</span>
456
+ </Show>
457
+ </div>
458
+
459
+ {/* Embedded content for active step */}
460
+ <Show when={step.status === 'active' && step.content}>
461
+ <div class="mt-2 ml-6">
462
+ <SectionRenderer
463
+ section={step.content}
464
+ filters={{}}
465
+ onFilterChange={props.onFilterChange}
466
+ onAction={props.onAction}
467
+ />
468
+ </div>
294
469
  </Show>
295
- </span>
470
+ </div>
296
471
  )}
297
472
  </For>
298
- <Show when={entries().length === 0}>
299
- <p class="text-xs text-gray-400 italic">No filters applied</p>
473
+
474
+ {/* Next button */}
475
+ <Show when={stepsData().steps.some((s: any) => s.status === 'active')}>
476
+ <div class="flex justify-end">
477
+ <button type="button" onClick={() => props.onAction?.('next_step', { step: stepsData().currentStep })}
478
+ 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">
479
+ 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>
480
+ </button>
481
+ </div>
300
482
  </Show>
301
483
  </div>
302
484
  )
303
485
  }
304
486
 
487
+ // ─── Action Section ──────────────────────────────────────────
488
+
305
489
  const ActionSection: Component<{
306
490
  content: unknown
307
491
  onAction?: (action: string, data?: unknown) => void
308
492
  }> = (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 []
312
- }
313
-
493
+ const actions = () => Array.isArray(props.content) ? props.content as Array<{ label: string; value?: string; action?: string; variant?: string; icon?: string }> : []
314
494
  return (
315
495
  <div class="flex flex-wrap gap-2">
316
496
  <For each={actions()}>
317
497
  {(item) => (
318
- <button
319
- type="button"
320
- onClick={() => props.onAction?.(item.value || item.action || item.label, item)}
498
+ <button type="button" onClick={() => props.onAction?.(item.value || item.action || item.label, item)}
321
499
  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
- >
500
+ item.variant === 'primary' ? 'bg-blue-600 text-white hover:bg-blue-700'
501
+ : item.variant === 'danger' ? 'bg-red-600 text-white hover:bg-red-700'
502
+ : 'border border-gray-200 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
503
+ }`}>
329
504
  <Show when={item.icon}><span class="mr-1">{item.icon}</span></Show>
330
505
  {item.label}
331
506
  </button>
@@ -335,31 +510,149 @@ const ActionSection: Component<{
335
510
  )
336
511
  }
337
512
 
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 []
513
+ // ─── Understanding Section agent comprehension ─────────────
514
+
515
+ const UnderstandingSection: Component<{ content: unknown }> = (props) => {
516
+ const data = () => {
517
+ const c = props.content as any
518
+ return { detections: c?.detections || [], warnings: c?.warnings || [] }
519
+ }
520
+
521
+ const confidenceClass = (conf?: string) => {
522
+ switch (conf) {
523
+ case 'high': return 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400'
524
+ case 'medium': return 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400'
525
+ case 'low': return 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400'
526
+ default: return 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
527
+ }
342
528
  }
343
529
 
344
530
  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 ${
353
- 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'
355
- : 'text-gray-400'
356
- }`}>
357
- {step.status === 'done' ? '✓' : step.status === 'active' ? '●' : '○'}
358
- {step.label}
531
+ <div class="space-y-2">
532
+ <div class="space-y-1.5">
533
+ <For each={data().detections}>
534
+ {(det: any) => (
535
+ <div class="flex items-center gap-2 text-sm">
536
+ <span class={`px-1.5 py-0.5 text-xs font-medium rounded ${confidenceClass(det.confidence)}`}>
537
+ {det.label}
538
+ </span>
539
+ <span class="text-gray-900 dark:text-white">{det.value}</span>
359
540
  </div>
360
- </>
361
- )}
362
- </For>
541
+ )}
542
+ </For>
543
+ </div>
544
+ <Show when={data().warnings.length > 0}>
545
+ <div class="space-y-1">
546
+ <For each={data().warnings}>
547
+ {(w: string) => (
548
+ <div class="flex items-start gap-1.5 text-xs text-amber-600 dark:text-amber-400">
549
+ <span class="flex-shrink-0">⚠️</span>
550
+ <span>{w}</span>
551
+ </div>
552
+ )}
553
+ </For>
554
+ </div>
555
+ </Show>
556
+ </div>
557
+ )
558
+ }
559
+
560
+ // ─── Feedback Section — thumbs up/down ───────────────────────
561
+
562
+ const FeedbackSection: Component<{
563
+ content: unknown
564
+ onAction?: (action: string, data?: unknown) => void
565
+ }> = (props) => {
566
+ const [comment, setComment] = createSignal('')
567
+ const data = () => {
568
+ const c = props.content as any
569
+ return {
570
+ question: c?.question || '',
571
+ approve: c?.approve || { label: 'Yes', value: 'approve' },
572
+ reject: c?.reject || { label: 'No', value: 'reject' },
573
+ allowComment: c?.allowComment ?? false,
574
+ commentPlaceholder: c?.commentPlaceholder || 'Add a comment...',
575
+ }
576
+ }
577
+
578
+ const handleFeedback = (approved: boolean) => {
579
+ const d = data()
580
+ props.onAction?.('feedback', {
581
+ approved,
582
+ value: approved ? d.approve.value : d.reject.value,
583
+ comment: comment(),
584
+ })
585
+ }
586
+
587
+ return (
588
+ <div class="space-y-3">
589
+ <p class="text-sm text-gray-700 dark:text-gray-300">{data().question}</p>
590
+ <div class="flex gap-2">
591
+ <button type="button" onClick={() => handleFeedback(true)}
592
+ class="px-3 py-1.5 text-sm font-medium rounded-lg bg-green-600 text-white hover:bg-green-700 transition-colors flex items-center gap-1">
593
+ &#128077; {data().approve.label}
594
+ </button>
595
+ <button type="button" onClick={() => handleFeedback(false)}
596
+ class="px-3 py-1.5 text-sm font-medium rounded-lg border border-gray-200 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center gap-1">
597
+ &#128078; {data().reject.label}
598
+ </button>
599
+ </div>
600
+ <Show when={data().allowComment}>
601
+ <input
602
+ type="text"
603
+ value={comment()}
604
+ onInput={(e) => setComment(e.currentTarget.value)}
605
+ placeholder={data().commentPlaceholder}
606
+ class="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:border-blue-400 outline-none"
607
+ />
608
+ </Show>
609
+ </div>
610
+ )
611
+ }
612
+
613
+ // ─── Prompt Section — agent interpretation ───────────────────
614
+
615
+ const PromptSection: Component<{
616
+ content: unknown
617
+ onAction?: (action: string, data?: unknown) => void
618
+ }> = (props) => {
619
+ const data = () => {
620
+ const c = props.content as any
621
+ return {
622
+ originalQuery: c?.originalQuery || '',
623
+ interpretation: c?.interpretation || '',
624
+ extracted: c?.extracted || {},
625
+ plan: c?.plan || '',
626
+ editable: c?.editable ?? false,
627
+ }
628
+ }
629
+
630
+ return (
631
+ <div class="space-y-2">
632
+ <Show when={data().originalQuery}>
633
+ <p class="text-xs text-gray-500 dark:text-gray-400 italic">"{data().originalQuery}"</p>
634
+ </Show>
635
+ <div class="space-y-1">
636
+ <For each={Object.entries(data().extracted)}>
637
+ {([key, value]) => (
638
+ <div class="flex gap-2 text-sm">
639
+ <span class="text-gray-500 dark:text-gray-400 font-medium min-w-[80px]">{key}:</span>
640
+ <span class="text-gray-900 dark:text-white">{String(value)}</span>
641
+ </div>
642
+ )}
643
+ </For>
644
+ </div>
645
+ <Show when={data().plan}>
646
+ <div class="mt-2 px-3 py-2 bg-blue-50 dark:bg-blue-900/10 rounded-lg text-sm text-blue-700 dark:text-blue-300">
647
+ <span class="font-medium">Plan:</span> {data().plan}
648
+ </div>
649
+ </Show>
650
+ <Show when={data().editable}>
651
+ <button type="button" onClick={() => props.onAction?.('edit_prompt', data())}
652
+ class="text-xs text-blue-600 dark:text-blue-400 hover:underline flex items-center gap-1">
653
+ &#9998; Modify
654
+ </button>
655
+ </Show>
363
656
  </div>
364
657
  )
365
658
  }