@seed-ship/mcp-ui-solid 2.13.0 → 2.15.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.
@@ -20,6 +20,14 @@ export interface ScratchpadPanelProps {
20
20
  /** Called when user clicks retry on error state */
21
21
  onRetry?: () => void
22
22
  onClose?: () => void
23
+ /** When true, action buttons show loading spinner and stay open until next server update */
24
+ asyncAction?: boolean
25
+ /** When true (set by server), scratchpad stays visible during stream */
26
+ pinned?: boolean
27
+ /** Log events/actions to console */
28
+ debug?: boolean
29
+ /** Show mini debug overlay */
30
+ debugOverlay?: boolean
23
31
  closable?: boolean
24
32
  autoCloseDelay?: number
25
33
  collapsible?: boolean
@@ -38,29 +46,52 @@ const STATUS_BADGES: Record<ScratchpadState['status'], { label: string; class: s
38
46
  export const ScratchpadPanel: Component<ScratchpadPanelProps> = (props) => {
39
47
  const [collapsed, setCollapsed] = createSignal(false)
40
48
  const [localPreview, setLocalPreview] = createSignal<ScratchpadState['preview']>(undefined)
49
+ const [loadingAction, setLoadingAction] = createSignal<string | null>(null)
41
50
  let previewTimer: ReturnType<typeof setTimeout> | null = null
42
51
  const badge = () => STATUS_BADGES[props.state.status] || STATUS_BADGES.loading
43
52
  const isClosable = () => props.closable !== false
44
53
  const isCollapsible = () => props.collapsible !== false
45
54
  const preview = () => localPreview() || props.state.preview
46
55
  const hasFilters = () => Object.keys(props.state.filters || {}).length > 0
56
+ const [eventCount, setEventCount] = createSignal(0)
57
+ const [lastEvent, setLastEvent] = createSignal('')
58
+
59
+ const debugLog = (event: string, data?: any) => {
60
+ if (!props.debug) return
61
+ setEventCount(c => c + 1)
62
+ setLastEvent(event)
63
+ console.log(`[ScratchpadPanel:${props.state.id}] ${event}`, data || '')
64
+ }
47
65
 
48
66
  // Action aliases that auto-close the scratchpad
49
67
  const CLOSE_ALIASES = new Set(['done', 'close', 'dismiss', 'validate', 'cancel', 'sufficient'])
50
68
 
51
69
  const handleAction = (action: string, data?: unknown) => {
70
+ debugLog('onAction', { action, asyncAction: props.asyncAction, data })
71
+ if (props.asyncAction && !CLOSE_ALIASES.has(action)) {
72
+ setLoadingAction(action)
73
+ }
52
74
  props.onAction?.(action, data)
53
75
  if (CLOSE_ALIASES.has(action) && props.onClose) {
54
76
  props.onClose()
55
77
  }
56
78
  }
57
79
 
58
- // Auto-close on complete
80
+ // Debug: log state changes
59
81
  createEffect(() => {
60
- if (props.state.status === 'complete' && props.autoCloseDelay) {
82
+ debugLog('state', { status: props.state.status, sections: props.state.sections?.length, filters: Object.keys(props.state.filters || {}), turn: props.state.turn })
83
+ })
84
+
85
+ // Auto-close on complete (unless pinned)
86
+ createEffect(() => {
87
+ if (props.state.status === 'complete' && props.autoCloseDelay && !props.pinned) {
61
88
  const timer = setTimeout(() => props.onClose?.(), props.autoCloseDelay)
62
89
  onCleanup(() => clearTimeout(timer))
63
90
  }
91
+ // Clear loading action when server responds
92
+ if (props.state.status !== 'processing' && loadingAction()) {
93
+ setLoadingAction(null)
94
+ }
64
95
  })
65
96
 
66
97
  // Preview auto-refresh when filters change
@@ -257,6 +288,13 @@ export const ScratchpadPanel: Component<ScratchpadPanelProps> = (props) => {
257
288
  <style>{`
258
289
  @keyframes scratchpad-slide-down { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: translateY(0); } }
259
290
  `}</style>
291
+
292
+ {/* Debug overlay */}
293
+ <Show when={props.debugOverlay}>
294
+ <div class="absolute bottom-1 right-1 px-2 py-1 text-[9px] font-mono bg-black/80 text-green-400 rounded z-50 pointer-events-none">
295
+ {props.state.id} | ev:{eventCount()} | sec:{props.state.sections?.length || 0} | {props.state.status} | last:{lastEvent()}
296
+ </div>
297
+ </Show>
260
298
  </div>
261
299
  )
262
300
  }
@@ -285,6 +323,9 @@ const SectionRenderer: Component<{
285
323
  <Match when={props.section.type === 'feedback'}><FeedbackSection content={props.section.content} onAction={props.onAction} /></Match>
286
324
  <Match when={props.section.type === 'prompt'}><PromptSection content={props.section.content} onAction={props.onAction} /></Match>
287
325
  <Match when={props.section.type === 'stepper'}><StepperProgressSection content={props.section.content} /></Match>
326
+ <Match when={props.section.type === 'error'}><ErrorSectionRenderer content={props.section.content} onAction={props.onAction} /></Match>
327
+ <Match when={props.section.type === 'source_card'}><SourceCardSection content={props.section.content} /></Match>
328
+ <Match when={props.section.type === 'diff'}><DiffSection content={props.section.content} /></Match>
288
329
  <Match when={true}><pre class="text-xs text-gray-500 overflow-auto">{JSON.stringify(props.section.content, null, 2)}</pre></Match>
289
330
  </Switch>
290
331
  </div>
@@ -798,3 +839,116 @@ const StepperProgressSection: Component<{ content: unknown }> = (props) => {
798
839
  </Show>
799
840
  )
800
841
  }
842
+
843
+ // ─── Error Section (F6) ──────────────────────────────────────
844
+
845
+ const ErrorSectionRenderer: Component<{
846
+ content: unknown
847
+ onAction?: (action: string, data?: unknown) => void
848
+ }> = (props) => {
849
+ const [showDetails, setShowDetails] = createSignal(false)
850
+ const data = () => {
851
+ const c = props.content as any
852
+ return { message: c?.message || 'Error', severity: c?.severity || 'error', retryAction: c?.retryAction, retryLabel: c?.retryLabel || 'Retry', details: c?.details, timestamp: c?.timestamp }
853
+ }
854
+ const isWarning = () => data().severity === 'warning'
855
+
856
+ return (
857
+ <div class={`rounded-lg px-3 py-2 text-sm ${isWarning() ? 'bg-amber-50 dark:bg-amber-900/10 text-amber-700 dark:text-amber-400 border border-amber-200 dark:border-amber-800' : 'bg-red-50 dark:bg-red-900/10 text-red-700 dark:text-red-400 border border-red-200 dark:border-red-800'}`}>
858
+ <div class="flex items-start gap-2">
859
+ <span class="flex-shrink-0">{isWarning() ? '⚠️' : '❌'}</span>
860
+ <div class="flex-1">
861
+ <p>{data().message}</p>
862
+ <div class="flex gap-2 mt-2">
863
+ <Show when={data().retryAction}>
864
+ <button type="button" onClick={() => props.onAction?.(data().retryAction!)} class={`px-2 py-1 text-xs font-medium rounded ${isWarning() ? 'bg-amber-600 text-white hover:bg-amber-700' : 'bg-red-600 text-white hover:bg-red-700'} transition-colors`}>&#128260; {data().retryLabel}</button>
865
+ </Show>
866
+ <Show when={data().details}>
867
+ <button type="button" onClick={() => setShowDetails(!showDetails())} class="px-2 py-1 text-xs opacity-70 hover:opacity-100">&#9654; Details</button>
868
+ </Show>
869
+ </div>
870
+ <Show when={showDetails() && data().details}>
871
+ <pre class="mt-2 text-xs opacity-70 overflow-x-auto">{data().details}</pre>
872
+ </Show>
873
+ </div>
874
+ </div>
875
+ </div>
876
+ )
877
+ }
878
+
879
+ // ─── Source Card Section (F9) ────────────────────────────────
880
+
881
+ const SourceCardSection: Component<{ content: unknown }> = (props) => {
882
+ const data = () => {
883
+ const c = props.content as any
884
+ return { name: c?.name || 'Source', status: c?.status || 'available', capabilities: c?.capabilities || [], latency_ms: c?.latency_ms, freshness: c?.freshness, row_count: c?.row_count }
885
+ }
886
+ const statusIcon = () => ({ queried: '✅', available: '📦', error: '❌' } as Record<string, string>)[data().status] || '📦'
887
+
888
+ return (
889
+ <div class="rounded-lg border border-gray-200 dark:border-gray-700 px-3 py-2">
890
+ <div class="flex items-center justify-between mb-1.5">
891
+ <span class="text-sm font-medium text-gray-900 dark:text-white">{statusIcon()} {data().name}</span>
892
+ <Show when={data().row_count !== undefined}>
893
+ <span class="text-xs font-bold text-blue-600 dark:text-blue-400">{data().row_count?.toLocaleString()} results</span>
894
+ </Show>
895
+ </div>
896
+ <div class="flex flex-wrap gap-1.5 mb-1">
897
+ <For each={data().capabilities}>
898
+ {(cap: any) => (
899
+ <span class={`text-[10px] px-1.5 py-0.5 rounded ${cap.supported ? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400' : 'bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400'}`}>
900
+ {cap.supported ? '✅' : '❌'} {cap.label}
901
+ </span>
902
+ )}
903
+ </For>
904
+ </div>
905
+ <Show when={data().freshness || data().latency_ms}>
906
+ <p class="text-[10px] text-gray-400">{[data().freshness, data().latency_ms ? `${data().latency_ms}ms` : ''].filter(Boolean).join(' · ')}</p>
907
+ </Show>
908
+ </div>
909
+ )
910
+ }
911
+
912
+ // ─── Diff Section (F10) ──────────────────────────────────────
913
+
914
+ const DiffSection: Component<{ content: unknown }> = (props) => {
915
+ const data = () => {
916
+ const c = props.content as any
917
+ return { left: c?.left || { label: 'A', rows: [] }, right: c?.right || { label: 'B', rows: [] }, highlight: c?.highlight_columns || [] }
918
+ }
919
+ const allKeys = () => {
920
+ const l = data().left.rows[0] || {}
921
+ const r = data().right.rows[0] || {}
922
+ return [...new Set([...Object.keys(l), ...Object.keys(r)])]
923
+ }
924
+
925
+ return (
926
+ <div class="overflow-x-auto">
927
+ <table class="min-w-full text-xs">
928
+ <thead>
929
+ <tr>
930
+ <th class="px-2 py-1 text-left text-gray-500 dark:text-gray-400"></th>
931
+ <th class="px-2 py-1 text-left font-medium text-blue-600 dark:text-blue-400">{data().left.label}</th>
932
+ <th class="px-2 py-1 text-left font-medium text-purple-600 dark:text-purple-400">{data().right.label}</th>
933
+ </tr>
934
+ </thead>
935
+ <tbody>
936
+ <For each={allKeys()}>
937
+ {(key) => {
938
+ const lVal = () => data().left.rows[0]?.[key]
939
+ const rVal = () => data().right.rows[0]?.[key]
940
+ const isDiff = () => String(lVal()) !== String(rVal()) && data().highlight.includes(key)
941
+ return (
942
+ <tr class={`border-t border-gray-100 dark:border-gray-700 ${isDiff() ? 'bg-yellow-50 dark:bg-yellow-900/10' : ''}`}>
943
+ <td class="px-2 py-1 font-mono text-gray-500 dark:text-gray-400">{key}</td>
944
+ <td class="px-2 py-1 text-gray-900 dark:text-white">{lVal() !== undefined ? String(lVal()) : '—'}</td>
945
+ <td class="px-2 py-1 text-gray-900 dark:text-white">{rVal() !== undefined ? String(rVal()) : '—'}</td>
946
+ </tr>
947
+ )
948
+ }}
949
+ </For>
950
+ </tbody>
951
+ </table>
952
+ </div>
953
+ )
954
+ }
@@ -366,7 +366,7 @@ export interface ScratchpadState {
366
366
  export interface ScratchpadSection {
367
367
  id: string
368
368
  title: string
369
- type: 'data' | 'filter' | 'preview' | 'message' | 'action' | 'steps' | 'form' | 'understanding' | 'feedback' | 'prompt' | 'stepper'
369
+ type: 'data' | 'filter' | 'preview' | 'message' | 'action' | 'steps' | 'form' | 'understanding' | 'feedback' | 'prompt' | 'stepper' | 'error' | 'source_card' | 'diff'
370
370
  content: unknown
371
371
  /** Can the human edit this section? */
372
372
  editable: boolean
@@ -385,6 +385,8 @@ export interface ScratchpadEvent {
385
385
  sections?: ScratchpadSection[]
386
386
  /** How to merge sections on update (default: 'replace') */
387
387
  sectionMode?: 'replace' | 'append' | 'upsert'
388
+ /** If true, scratchpad stays visible during stream (no auto-close on complete) */
389
+ pinned?: boolean
388
390
  filters?: Record<string, string | string[]>
389
391
  preview?: { count: number; rows?: Record<string, unknown>[]; summary: string }
390
392
  agentMessages?: Array<{ text: string; type: 'info' | 'question' | 'warning' }>