@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.
- package/dist/components/ScratchpadPanel.cjs +479 -178
- package/dist/components/ScratchpadPanel.cjs.map +1 -1
- package/dist/components/ScratchpadPanel.d.ts +2 -13
- package/dist/components/ScratchpadPanel.d.ts.map +1 -1
- package/dist/components/ScratchpadPanel.js +480 -179
- package/dist/components/ScratchpadPanel.js.map +1 -1
- package/dist/types/chat-bus.d.ts +13 -0
- package/dist/types/chat-bus.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/ScratchpadPanel.tsx +287 -168
- package/src/types/chat-bus.ts +9 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -1,30 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* ScratchpadPanel
|
|
3
|
-
* v2.
|
|
2
|
+
* ScratchpadPanel v3 — Full 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
|
-
|
|
57
|
-
|
|
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=
|
|
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={
|
|
86
|
+
onClick={() => isCollapsible() && setCollapsed(!collapsed())}
|
|
69
87
|
>
|
|
70
88
|
<div class="flex items-center gap-2">
|
|
71
89
|
<span class="text-base">📝</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
|
-
|
|
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
|
|
107
|
+
{/* Body */}
|
|
98
108
|
<Show when={!collapsed()}>
|
|
99
|
-
<div style={{ "max-height": props.maxHeight || "
|
|
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={
|
|
144
|
+
<Show when={preview()}>
|
|
137
145
|
<div class="px-4 py-3 border-t border-gray-100 dark:border-gray-700">
|
|
138
|
-
<Show when={
|
|
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">{
|
|
147
|
-
<Show when={
|
|
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
|
-
|
|
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">🔍</span>
|
|
176
165
|
<p class="text-sm text-gray-500 dark:text-gray-400">No results for these filters</p>
|
|
177
|
-
<
|
|
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
|
-
|
|
219
|
-
</Match>
|
|
220
|
-
<Match when={props.section.type === '
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
<Match when={props.section.
|
|
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
|
-
// ───
|
|
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
|
-
|
|
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
|
|
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={
|
|
281
|
-
{(
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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}`}>×</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={
|
|
299
|
-
<p class="text-xs text-gray-400 italic">No filters
|
|
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
|
-
|
|
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
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
<
|
|
316
|
-
<For each={
|
|
317
|
-
{(
|
|
318
|
-
<
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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="
|
|
346
|
-
<For each={
|
|
347
|
-
{(step
|
|
348
|
-
|
|
349
|
-
<
|
|
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
|
|
422
|
+
: step.status === 'active' ? 'text-blue-600 dark:text-blue-400'
|
|
355
423
|
: 'text-gray-400'
|
|
356
424
|
}`}>
|
|
357
|
-
{step.status === 'done' ? '
|
|
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>
|
package/src/types/chat-bus.ts
CHANGED
|
@@ -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 {
|