@miragon/mcp-toolkit-ui 1.1.0 → 2.0.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.
Files changed (77) hide show
  1. package/dist/app/builder/BuilderBanners.d.ts +19 -0
  2. package/dist/app/builder/BuilderBanners.d.ts.map +1 -0
  3. package/dist/app/builder/BuilderBanners.js +22 -0
  4. package/dist/app/builder/BuilderBanners.js.map +1 -0
  5. package/dist/app/builder/CanvasRow.d.ts +26 -0
  6. package/dist/app/builder/CanvasRow.d.ts.map +1 -0
  7. package/dist/app/builder/CanvasRow.js +98 -0
  8. package/dist/app/builder/CanvasRow.js.map +1 -0
  9. package/dist/app/builder/CellPropsSheet.d.ts +19 -0
  10. package/dist/app/builder/CellPropsSheet.d.ts.map +1 -0
  11. package/dist/app/builder/CellPropsSheet.js +28 -0
  12. package/dist/app/builder/CellPropsSheet.js.map +1 -0
  13. package/dist/app/builder/EmptyHint.d.ts +6 -0
  14. package/dist/app/builder/EmptyHint.d.ts.map +1 -0
  15. package/dist/app/builder/EmptyHint.js +10 -0
  16. package/dist/app/builder/EmptyHint.js.map +1 -0
  17. package/dist/app/builder/LayoutSurface.d.ts +27 -0
  18. package/dist/app/builder/LayoutSurface.d.ts.map +1 -0
  19. package/dist/app/builder/LayoutSurface.js +45 -0
  20. package/dist/app/builder/LayoutSurface.js.map +1 -0
  21. package/dist/app/builder/Workspace.d.ts +9 -3
  22. package/dist/app/builder/Workspace.d.ts.map +1 -1
  23. package/dist/app/builder/Workspace.js +3 -97
  24. package/dist/app/builder/Workspace.js.map +1 -1
  25. package/dist/app/builder/builder-reducer.d.ts.map +1 -1
  26. package/dist/app/builder/builder-reducer.js +176 -164
  27. package/dist/app/builder/builder-reducer.js.map +1 -1
  28. package/dist/app/builder/use-builder-catalogue.d.ts +33 -0
  29. package/dist/app/builder/use-builder-catalogue.d.ts.map +1 -0
  30. package/dist/app/builder/use-builder-catalogue.js +155 -0
  31. package/dist/app/builder/use-builder-catalogue.js.map +1 -0
  32. package/dist/app/builder/use-key-step-entries.d.ts +22 -0
  33. package/dist/app/builder/use-key-step-entries.d.ts.map +1 -0
  34. package/dist/app/builder/use-key-step-entries.js +46 -0
  35. package/dist/app/builder/use-key-step-entries.js.map +1 -0
  36. package/dist/app/builder/use-save-dashboard.d.ts +33 -0
  37. package/dist/app/builder/use-save-dashboard.d.ts.map +1 -0
  38. package/dist/app/builder/use-save-dashboard.js +78 -0
  39. package/dist/app/builder/use-save-dashboard.js.map +1 -0
  40. package/dist/app/layout-builder.d.ts.map +1 -1
  41. package/dist/app/layout-builder.js +46 -277
  42. package/dist/app/layout-builder.js.map +1 -1
  43. package/dist/app/mcp-app-view-chrome.d.ts +77 -0
  44. package/dist/app/mcp-app-view-chrome.d.ts.map +1 -0
  45. package/dist/app/mcp-app-view-chrome.js +53 -0
  46. package/dist/app/mcp-app-view-chrome.js.map +1 -0
  47. package/dist/app/mcp-app-view.d.ts +3 -9
  48. package/dist/app/mcp-app-view.d.ts.map +1 -1
  49. package/dist/app/mcp-app-view.js +4 -31
  50. package/dist/app/mcp-app-view.js.map +1 -1
  51. package/dist/render-cases-primitives.d.ts +17 -0
  52. package/dist/render-cases-primitives.d.ts.map +1 -0
  53. package/dist/render-cases-primitives.js +88 -0
  54. package/dist/render-cases-primitives.js.map +1 -0
  55. package/dist/render-cases.d.ts +4 -0
  56. package/dist/render-cases.d.ts.map +1 -0
  57. package/dist/render-cases.js +140 -0
  58. package/dist/render-cases.js.map +1 -0
  59. package/package.json +10 -5
  60. package/src/app/builder/BuilderBanners.tsx +61 -0
  61. package/src/app/builder/CanvasRow.tsx +316 -0
  62. package/src/app/builder/CellPropsSheet.tsx +47 -0
  63. package/src/app/builder/EmptyHint.tsx +29 -0
  64. package/src/app/builder/LayoutSurface.tsx +140 -0
  65. package/src/app/builder/Workspace.tsx +33 -342
  66. package/src/app/builder/builder-reducer.ts +204 -170
  67. package/src/app/builder/use-builder-catalogue.ts +187 -0
  68. package/src/app/builder/use-key-step-entries.ts +60 -0
  69. package/src/app/builder/use-save-dashboard.ts +97 -0
  70. package/src/app/layout-builder.tsx +92 -497
  71. package/src/app/mcp-app-view-chrome.tsx +204 -0
  72. package/src/app/mcp-app-view.tsx +32 -140
  73. package/src/lib/parse-tool-result.property.test.ts +90 -0
  74. package/src/render-cases-primitives.tsx +255 -0
  75. package/src/render-cases.tsx +239 -0
  76. package/src/render-smoke.test.tsx +100 -0
  77. package/src/ui-catalog.test.ts +52 -0
@@ -97,193 +97,227 @@ function mapActiveRows(
97
97
  }
98
98
 
99
99
  // -------------------------------------------------------------------------- //
100
- // Reducer
100
+ // Per-action handlers (dispatch table)
101
101
  // -------------------------------------------------------------------------- //
102
102
 
103
- export function draftReducer(state: BuilderDraftState, action: BuilderAction): BuilderDraftState {
104
- switch (action.type) {
105
- case "addWidgetToRow": {
106
- const rows = activeRowsOf(state.draft, state.activeTabIndex)
107
- const safeLen = rows.length > 0 ? rows.length : 1
108
- const idx = Math.min(Math.max(action.targetRowIdx ?? state.focusedRowIndex, 0), safeLen - 1)
109
- const draft = mapActiveRows(state.draft, state.activeTabIndex, (rs) => {
110
- const safeRows = rs.length > 0 ? rs : emptyRows()
111
- return safeRows.map((r, i) =>
112
- i === idx
113
- ? { row: [...r.row, { widget: action.widgetId, span: action.defaultSpan }] }
114
- : r,
115
- )
116
- })
117
- return { ...state, draft, focusedRowIndex: idx }
118
- }
103
+ type ActionOf<K extends BuilderAction["type"]> = Extract<BuilderAction, { type: K }>
119
104
 
120
- case "addRow": {
121
- let newIndex = state.focusedRowIndex
122
- const draft = mapActiveRows(state.draft, state.activeTabIndex, (rows) => {
123
- const next = [...rows, { row: [] }]
124
- newIndex = next.length - 1
125
- return next
126
- })
127
- return { ...state, draft, focusedRowIndex: newIndex }
128
- }
105
+ function addWidgetToRow(
106
+ state: BuilderDraftState,
107
+ action: ActionOf<"addWidgetToRow">,
108
+ ): BuilderDraftState {
109
+ const rows = activeRowsOf(state.draft, state.activeTabIndex)
110
+ const safeLen = rows.length > 0 ? rows.length : 1
111
+ const idx = Math.min(Math.max(action.targetRowIdx ?? state.focusedRowIndex, 0), safeLen - 1)
112
+ const draft = mapActiveRows(state.draft, state.activeTabIndex, (rs) => {
113
+ const safeRows = rs.length > 0 ? rs : emptyRows()
114
+ return safeRows.map((r, i) =>
115
+ i === idx ? { row: [...r.row, { widget: action.widgetId, span: action.defaultSpan }] } : r,
116
+ )
117
+ })
118
+ return { ...state, draft, focusedRowIndex: idx }
119
+ }
129
120
 
130
- case "removeRow": {
131
- const draft = mapActiveRows(state.draft, state.activeTabIndex, (rows) => {
132
- const next = rows.filter((_, i) => i !== action.rowIdx)
133
- return next.length > 0 ? next : emptyRows()
134
- })
135
- // Matches the previous behaviour: decrement, floored at 0. Finding [9]
136
- // clamping is scoped to tab switch / removeTab, so this stays as-is.
137
- return { ...state, draft, focusedRowIndex: Math.max(0, state.focusedRowIndex - 1) }
138
- }
121
+ function addRow(state: BuilderDraftState): BuilderDraftState {
122
+ let newIndex = state.focusedRowIndex
123
+ const draft = mapActiveRows(state.draft, state.activeTabIndex, (rows) => {
124
+ const next = [...rows, { row: [] }]
125
+ newIndex = next.length - 1
126
+ return next
127
+ })
128
+ return { ...state, draft, focusedRowIndex: newIndex }
129
+ }
139
130
 
140
- case "moveRow": {
141
- const target = action.rowIdx + action.direction
142
- const rows = activeRowsOf(state.draft, state.activeTabIndex)
143
- if (target < 0 || target >= rows.length) return state
144
- const draft = mapActiveRows(state.draft, state.activeTabIndex, (rs) => {
145
- const next = [...rs]
146
- const [moved] = next.splice(action.rowIdx, 1)
147
- if (!moved) return rs
148
- next.splice(target, 0, moved)
149
- return next
150
- })
151
- return { ...state, draft, focusedRowIndex: target }
152
- }
131
+ function removeRow(state: BuilderDraftState, action: ActionOf<"removeRow">): BuilderDraftState {
132
+ const draft = mapActiveRows(state.draft, state.activeTabIndex, (rows) => {
133
+ const next = rows.filter((_, i) => i !== action.rowIdx)
134
+ return next.length > 0 ? next : emptyRows()
135
+ })
136
+ // Matches the previous behaviour: decrement, floored at 0. Finding [9]
137
+ // clamping is scoped to tab switch / removeTab, so this stays as-is.
138
+ return { ...state, draft, focusedRowIndex: Math.max(0, state.focusedRowIndex - 1) }
139
+ }
153
140
 
154
- case "setCellSpan": {
155
- const clamped = Math.max(1, Math.min(12, action.span))
156
- const draft = mapActiveRows(state.draft, state.activeTabIndex, (rows) =>
157
- rows.map((r, i) =>
158
- i === action.rowIdx
159
- ? { row: r.row.map((c, j) => (j === action.cellIdx ? { ...c, span: clamped } : c)) }
160
- : r,
161
- ),
162
- )
163
- return { ...state, draft }
164
- }
141
+ function moveRow(state: BuilderDraftState, action: ActionOf<"moveRow">): BuilderDraftState {
142
+ const target = action.rowIdx + action.direction
143
+ const rows = activeRowsOf(state.draft, state.activeTabIndex)
144
+ if (target < 0 || target >= rows.length) return state
145
+ const draft = mapActiveRows(state.draft, state.activeTabIndex, (rs) => {
146
+ const next = [...rs]
147
+ const [moved] = next.splice(action.rowIdx, 1)
148
+ if (!moved) return rs
149
+ next.splice(target, 0, moved)
150
+ return next
151
+ })
152
+ return { ...state, draft, focusedRowIndex: target }
153
+ }
165
154
 
166
- case "removeCell": {
167
- const draft = mapActiveRows(state.draft, state.activeTabIndex, (rows) =>
168
- rows.map((r, i) =>
169
- i === action.rowIdx ? { row: r.row.filter((_, j) => j !== action.cellIdx) } : r,
170
- ),
171
- )
172
- return { ...state, draft }
173
- }
155
+ function setCellSpan(state: BuilderDraftState, action: ActionOf<"setCellSpan">): BuilderDraftState {
156
+ const clamped = Math.max(1, Math.min(12, action.span))
157
+ const draft = mapActiveRows(state.draft, state.activeTabIndex, (rows) =>
158
+ rows.map((r, i) =>
159
+ i === action.rowIdx
160
+ ? { row: r.row.map((c, j) => (j === action.cellIdx ? { ...c, span: clamped } : c)) }
161
+ : r,
162
+ ),
163
+ )
164
+ return { ...state, draft }
165
+ }
174
166
 
175
- case "setCellProps": {
176
- const draft = mapActiveRows(state.draft, state.activeTabIndex, (rows) =>
177
- rows.map((r, i) =>
178
- i === action.rowIdx
179
- ? {
180
- row: r.row.map((c, j) => {
181
- if (j !== action.cellIdx) return c
182
- if (action.next === undefined) {
183
- const rest: typeof c = { widget: c.widget }
184
- if (c.span !== undefined) rest.span = c.span
185
- return rest
186
- }
187
- return { ...c, props: action.next }
188
- }),
189
- }
190
- : r,
191
- ),
192
- )
193
- return { ...state, draft }
194
- }
167
+ function removeCell(state: BuilderDraftState, action: ActionOf<"removeCell">): BuilderDraftState {
168
+ const draft = mapActiveRows(state.draft, state.activeTabIndex, (rows) =>
169
+ rows.map((r, i) =>
170
+ i === action.rowIdx ? { row: r.row.filter((_, j) => j !== action.cellIdx) } : r,
171
+ ),
172
+ )
173
+ return { ...state, draft }
174
+ }
195
175
 
196
- case "setFocusedRow":
197
- return { ...state, focusedRowIndex: Math.max(0, action.rowIdx) }
176
+ function setCellProps(
177
+ state: BuilderDraftState,
178
+ action: ActionOf<"setCellProps">,
179
+ ): BuilderDraftState {
180
+ const draft = mapActiveRows(state.draft, state.activeTabIndex, (rows) =>
181
+ rows.map((r, i) =>
182
+ i === action.rowIdx
183
+ ? {
184
+ row: r.row.map((c, j) => {
185
+ if (j !== action.cellIdx) return c
186
+ if (action.next === undefined) {
187
+ const rest: typeof c = { widget: c.widget }
188
+ if (c.span !== undefined) rest.span = c.span
189
+ return rest
190
+ }
191
+ return { ...c, props: action.next }
192
+ }),
193
+ }
194
+ : r,
195
+ ),
196
+ )
197
+ return { ...state, draft }
198
+ }
198
199
 
199
- case "selectTab": {
200
- if (state.draft.kind !== "tabs") return state
201
- if (action.tabIdx < 0 || action.tabIdx >= state.draft.tabs.length) return state
202
- const rows = state.draft.tabs[action.tabIdx]?.rows ?? []
203
- return {
204
- ...state,
205
- activeTabIndex: action.tabIdx,
206
- focusedRowIndex: clampRowIndex(state.focusedRowIndex, rows.length),
207
- }
208
- }
200
+ function setFocusedRow(
201
+ state: BuilderDraftState,
202
+ action: ActionOf<"setFocusedRow">,
203
+ ): BuilderDraftState {
204
+ return { ...state, focusedRowIndex: Math.max(0, action.rowIdx) }
205
+ }
209
206
 
210
- case "startEditingTab":
211
- return { ...state, editingTabIdx: action.tabIdx }
207
+ function selectTab(state: BuilderDraftState, action: ActionOf<"selectTab">): BuilderDraftState {
208
+ if (state.draft.kind !== "tabs") return state
209
+ if (action.tabIdx < 0 || action.tabIdx >= state.draft.tabs.length) return state
210
+ const rows = state.draft.tabs[action.tabIdx]?.rows ?? []
211
+ return {
212
+ ...state,
213
+ activeTabIndex: action.tabIdx,
214
+ focusedRowIndex: clampRowIndex(state.focusedRowIndex, rows.length),
215
+ }
216
+ }
212
217
 
213
- case "stopEditingTab":
214
- return { ...state, editingTabIdx: null }
218
+ function startEditingTab(
219
+ state: BuilderDraftState,
220
+ action: ActionOf<"startEditingTab">,
221
+ ): BuilderDraftState {
222
+ return { ...state, editingTabIdx: action.tabIdx }
223
+ }
215
224
 
216
- case "addTab": {
217
- if (state.draft.kind === "tabs") {
218
- const label = `Tab ${state.draft.tabs.length + 1}`
219
- const nextTabs: DraftTab[] = [...state.draft.tabs, { label, rows: emptyRows() }]
220
- return {
221
- ...state,
222
- draft: { kind: "tabs", tabs: nextTabs },
223
- activeTabIndex: nextTabs.length - 1,
224
- }
225
- }
226
- const seededTabs: DraftTab[] = [
227
- { label: "Tab 1", rows: state.draft.rows },
228
- { label: "Tab 2", rows: emptyRows() },
229
- ]
230
- return { ...state, draft: { kind: "tabs", tabs: seededTabs }, activeTabIndex: 1 }
231
- }
225
+ function stopEditingTab(state: BuilderDraftState): BuilderDraftState {
226
+ return { ...state, editingTabIdx: null }
227
+ }
232
228
 
233
- case "renameTab": {
234
- const trimmed = action.label.trim()
235
- if (!trimmed) return state
236
- if (state.draft.kind !== "tabs") return state
237
- // Reject duplicates — Tabs keys by label, collisions break the
238
- // component. Signal the rejection so the UI can give feedback; the
239
- // old label stays put.
240
- if (state.draft.tabs.some((t, i) => i !== action.tabIdx && t.label === trimmed)) {
241
- return { ...state, renameRejectedAt: state.renameRejectedAt + 1 }
242
- }
243
- const tabs = state.draft.tabs.map((t, i) =>
244
- i === action.tabIdx ? { ...t, label: trimmed } : t,
245
- )
246
- return { ...state, draft: { kind: "tabs", tabs } }
229
+ function addTab(state: BuilderDraftState): BuilderDraftState {
230
+ if (state.draft.kind === "tabs") {
231
+ const label = `Tab ${state.draft.tabs.length + 1}`
232
+ const nextTabs: DraftTab[] = [...state.draft.tabs, { label, rows: emptyRows() }]
233
+ return {
234
+ ...state,
235
+ draft: { kind: "tabs", tabs: nextTabs },
236
+ activeTabIndex: nextTabs.length - 1,
247
237
  }
238
+ }
239
+ const seededTabs: DraftTab[] = [
240
+ { label: "Tab 1", rows: state.draft.rows },
241
+ { label: "Tab 2", rows: emptyRows() },
242
+ ]
243
+ return { ...state, draft: { kind: "tabs", tabs: seededTabs }, activeTabIndex: 1 }
244
+ }
248
245
 
249
- case "removeTab": {
250
- if (state.draft.kind !== "tabs") return state
251
- const tabs = state.draft.tabs.filter((_, i) => i !== action.tabIdx)
252
- const activeTabIndex = clampRowIndex(state.activeTabIndex, tabs.length)
253
- if (tabs.length === 0) {
254
- const draft: DraftLayout = { kind: "rows", rows: emptyRows() }
255
- return {
256
- ...state,
257
- draft,
258
- activeTabIndex: 0,
259
- editingTabIdx: null,
260
- focusedRowIndex: clampRowIndex(state.focusedRowIndex, draft.rows.length),
261
- }
262
- }
263
- if (tabs.length === 1 && tabs[0]) {
264
- const draft: DraftLayout = { kind: "rows", rows: tabs[0].rows }
265
- return {
266
- ...state,
267
- draft,
268
- activeTabIndex: 0,
269
- editingTabIdx: null,
270
- focusedRowIndex: clampRowIndex(state.focusedRowIndex, draft.rows.length),
271
- }
272
- }
273
- const rows = tabs[activeTabIndex]?.rows ?? []
274
- return {
275
- ...state,
276
- draft: { kind: "tabs", tabs },
277
- activeTabIndex,
278
- editingTabIdx: null,
279
- focusedRowIndex: clampRowIndex(state.focusedRowIndex, rows.length),
280
- }
281
- }
246
+ function renameTab(state: BuilderDraftState, action: ActionOf<"renameTab">): BuilderDraftState {
247
+ const trimmed = action.label.trim()
248
+ if (!trimmed) return state
249
+ if (state.draft.kind !== "tabs") return state
250
+ // Reject duplicates — Tabs keys by label, collisions break the
251
+ // component. Signal the rejection so the UI can give feedback; the
252
+ // old label stays put.
253
+ if (state.draft.tabs.some((t, i) => i !== action.tabIdx && t.label === trimmed)) {
254
+ return { ...state, renameRejectedAt: state.renameRejectedAt + 1 }
255
+ }
256
+ const tabs = state.draft.tabs.map((t, i) => (i === action.tabIdx ? { ...t, label: trimmed } : t))
257
+ return { ...state, draft: { kind: "tabs", tabs } }
258
+ }
282
259
 
283
- default: {
284
- // Exhaustiveness guard: a new action type without a case is a type error.
285
- const _never: never = action
286
- return _never
260
+ function removeTab(state: BuilderDraftState, action: ActionOf<"removeTab">): BuilderDraftState {
261
+ if (state.draft.kind !== "tabs") return state
262
+ const tabs = state.draft.tabs.filter((_, i) => i !== action.tabIdx)
263
+ // One tab left collapses back to a flat draft (zero left: a flat empty one).
264
+ if (tabs.length <= 1) {
265
+ const draft: DraftLayout = { kind: "rows", rows: tabs[0]?.rows ?? emptyRows() }
266
+ return {
267
+ ...state,
268
+ draft,
269
+ activeTabIndex: 0,
270
+ editingTabIdx: null,
271
+ focusedRowIndex: clampRowIndex(state.focusedRowIndex, draft.rows.length),
287
272
  }
288
273
  }
274
+ const activeTabIndex = clampRowIndex(state.activeTabIndex, tabs.length)
275
+ const rows = tabs[activeTabIndex]?.rows ?? []
276
+ return {
277
+ ...state,
278
+ draft: { kind: "tabs", tabs },
279
+ activeTabIndex,
280
+ editingTabIdx: null,
281
+ focusedRowIndex: clampRowIndex(state.focusedRowIndex, rows.length),
282
+ }
283
+ }
284
+
285
+ // -------------------------------------------------------------------------- //
286
+ // Reducer
287
+ // -------------------------------------------------------------------------- //
288
+
289
+ /**
290
+ * Dispatch table instead of a switch: one small handler per action. The
291
+ * mapped type keeps it exhaustive — a new `BuilderAction` variant without a
292
+ * matching entry is a type error (the table equivalent of the old switch's
293
+ * `never` default).
294
+ */
295
+ const handlers: {
296
+ [K in BuilderAction["type"]]: (state: BuilderDraftState, action: ActionOf<K>) => BuilderDraftState
297
+ } = {
298
+ addWidgetToRow,
299
+ addRow,
300
+ removeRow,
301
+ moveRow,
302
+ setCellSpan,
303
+ removeCell,
304
+ setCellProps,
305
+ setFocusedRow,
306
+ selectTab,
307
+ startEditingTab,
308
+ stopEditingTab,
309
+ addTab,
310
+ renameTab,
311
+ removeTab,
312
+ }
313
+
314
+ export function draftReducer(state: BuilderDraftState, action: BuilderAction): BuilderDraftState {
315
+ // TS cannot correlate `handlers[action.type]` with `action` across the
316
+ // union, so widen the picked handler once; the table type above already
317
+ // guarantees the per-variant pairing.
318
+ const handle = handlers[action.type] as (
319
+ state: BuilderDraftState,
320
+ action: BuilderAction,
321
+ ) => BuilderDraftState
322
+ return handle(state, action)
289
323
  }
@@ -0,0 +1,187 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react"
2
+ import type {
3
+ AvailableStep,
4
+ KeyCatalogEntry,
5
+ PipelineContext,
6
+ PipelineStepRef,
7
+ ReachableWidget,
8
+ UnreachableWidget,
9
+ } from "@miragon/mcp-toolkit-core"
10
+ import { parseToolResult } from "../../lib/parse-tool-result.js"
11
+ import {
12
+ entriesToKeys,
13
+ toPipelineContext,
14
+ type KeyEntry,
15
+ type WireContext,
16
+ } from "./builder-model.js"
17
+ import type { RefreshStatus } from "./labels.js"
18
+
19
+ // -------------------------------------------------------------------------- //
20
+ // Builder catalogue hook — live snapshots from `get-builder-catalogue`
21
+ // -------------------------------------------------------------------------- //
22
+
23
+ const EMPTY_CONTEXT: PipelineContext = { steps: {}, keys: {}, errors: [] }
24
+
25
+ /**
26
+ * Owns the builder's live catalogue: the snapshots fetched from the app-only
27
+ * `get-builder-catalogue` tool (the LLM never sees this data), the refresh
28
+ * status for the auto-apply chip, and the lookups derived from the
29
+ * snapshots. Extracted from `layout-builder.tsx` unchanged: fetch on mount
30
+ * (no debounce) so the builder is ready immediately, then debounce 600ms on
31
+ * every keys/steps edit. The ref pattern keeps the effect dependent on only
32
+ * `keyEntries` / `stepEntries`.
33
+ */
34
+ export function useBuilderCatalogue({
35
+ callTool,
36
+ catalogueToolName,
37
+ keyEntries,
38
+ stepEntries,
39
+ }: {
40
+ callTool: (name: string, args: object) => Promise<unknown>
41
+ catalogueToolName: string
42
+ keyEntries: KeyEntry[]
43
+ stepEntries: PipelineStepRef[]
44
+ }) {
45
+ // ── Live snapshots from get-builder-catalogue responses ─────────────────
46
+ // Start empty — the first effect below fetches the catalogue on mount.
47
+ const [liveReachable, setLiveReachable] = useState<ReachableWidget[]>([])
48
+ const [liveUnreachable, setLiveUnreachable] = useState<UnreachableWidget[]>([])
49
+ const [liveCatalog, setLiveCatalog] = useState<KeyCatalogEntry[]>([])
50
+ const [liveSteps, setLiveSteps] = useState<AvailableStep[]>([])
51
+ const [liveContext, setLiveContext] = useState<PipelineContext>(EMPTY_CONTEXT)
52
+ // Pipeline validation issues from the catalogue (Finding [10]/[6]). The
53
+ // catalogue is fail-soft, but `render-view` is fail-hard, so we surface
54
+ // these so the user sees — before committing — that the pipeline as
55
+ // configured wouldn't render.
56
+ const [validationIssues, setValidationIssues] = useState<string[]>([])
57
+
58
+ // ── Refresh status (for the auto-apply chip) ────────────────────────────
59
+ const [status, setStatus] = useState<RefreshStatus>("idle")
60
+ // Last catalogue-fetch failure (Finding [3]). Surfaced as a toolbar banner
61
+ // instead of only `console.warn`-ing, so the user knows the palette/context
62
+ // may be stale. Cleared on the next successful refresh.
63
+ const [refreshError, setRefreshError] = useState<string | null>(null)
64
+
65
+ const refreshBuilder = useCallback(async () => {
66
+ setStatus("refreshing")
67
+ try {
68
+ const result = await callTool(catalogueToolName, {
69
+ keys: entriesToKeys(keyEntries),
70
+ steps: stepEntries.filter((s) => s.step.trim().length > 0),
71
+ })
72
+ const sc = parseToolResult<{
73
+ reachableWidgets?: ReachableWidget[]
74
+ unreachableWidgets?: UnreachableWidget[]
75
+ availableSteps?: AvailableStep[]
76
+ keyCatalog?: KeyCatalogEntry[]
77
+ validationIssues?: string[]
78
+ context?: WireContext
79
+ } | null>(result)
80
+ if (sc?.reachableWidgets) setLiveReachable(sc.reachableWidgets)
81
+ if (sc?.unreachableWidgets) setLiveUnreachable(sc.unreachableWidgets)
82
+ if (sc?.availableSteps) setLiveSteps(sc.availableSteps)
83
+ if (sc?.keyCatalog) setLiveCatalog(sc.keyCatalog)
84
+ if (sc?.context) setLiveContext(toPipelineContext(sc.context))
85
+ // Pipeline issues are fail-soft in the catalogue but fail-hard in
86
+ // render-view; surface them so the user sees the render path wouldn't
87
+ // run as configured (Finding [10]/[6]). Coerce undefined → [] so a
88
+ // fixed pipeline clears a previously-shown warning.
89
+ setValidationIssues(sc?.validationIssues ?? [])
90
+ // A successful fetch clears any stale failure banner.
91
+ setRefreshError(null)
92
+ } catch (err) {
93
+ // Fail soft: when the host's server hasn't registered the app-only
94
+ // `get-builder-catalogue` tool (the builder platform is opt-in via
95
+ // `app.builder`), the call rejects. Degrade to the static catalogue
96
+ // already derived from the layout rather than crashing the iframe —
97
+ // but surface the failure so the user knows the palette may be stale
98
+ // (Finding [3]).
99
+ const reason = err instanceof Error ? err.message : String(err)
100
+ console.warn(
101
+ `[mcp-toolkit] builder catalogue fetch failed (${catalogueToolName}): ${reason}. ` +
102
+ "The builder platform may be disabled on this server (app.builder).",
103
+ )
104
+ setRefreshError(reason)
105
+ } finally {
106
+ setStatus("idle")
107
+ }
108
+ }, [catalogueToolName, callTool, keyEntries, stepEntries])
109
+
110
+ const refreshRef = useRef(refreshBuilder)
111
+ useEffect(() => {
112
+ refreshRef.current = refreshBuilder
113
+ }, [refreshBuilder])
114
+ const isFirstRun = useRef(true)
115
+ useEffect(() => {
116
+ if (isFirstRun.current) {
117
+ isFirstRun.current = false
118
+ void refreshRef.current()
119
+ return
120
+ }
121
+ setStatus("pending")
122
+ const handle = window.setTimeout(() => {
123
+ void refreshRef.current()
124
+ }, 600)
125
+ return () => {
126
+ window.clearTimeout(handle)
127
+ }
128
+ }, [keyEntries, stepEntries])
129
+
130
+ // ── Derived lookups ─────────────────────────────────────────────────────
131
+ const widgetById = useMemo(() => {
132
+ const map = new Map<string, ReachableWidget>()
133
+ for (const w of liveReachable) map.set(w.id, w)
134
+ return map
135
+ }, [liveReachable])
136
+
137
+ // Per-widget propsSchema lookup. Merged from both reachable and
138
+ // unreachable lists because a saved layout can reference a widget whose
139
+ // `requires` aren't currently satisfied — we still want the user to be
140
+ // able to edit its props.
141
+ const propsSchemaByWidgetId = useMemo(() => {
142
+ const map = new Map<string, Record<string, unknown>>()
143
+ for (const w of liveReachable) {
144
+ if (w.propsSchema) map.set(w.id, w.propsSchema)
145
+ }
146
+ for (const w of liveUnreachable) {
147
+ if (w.propsSchema) map.set(w.id, w.propsSchema)
148
+ }
149
+ return map
150
+ }, [liveReachable, liveUnreachable])
151
+
152
+ const paletteByApp = useMemo(() => {
153
+ const groups = new Map<string, ReachableWidget[]>()
154
+ for (const w of liveReachable) {
155
+ if (!groups.has(w.app)) groups.set(w.app, [])
156
+ groups.get(w.app)!.push(w)
157
+ }
158
+ return [...groups.entries()].sort(([a], [b]) => a.localeCompare(b))
159
+ }, [liveReachable])
160
+
161
+ const liveKeyCount = useMemo(() => liveCatalog.filter((c) => c.inContext).length, [liveCatalog])
162
+
163
+ const stepsByApp = useMemo(() => {
164
+ const groups = new Map<string, AvailableStep[]>()
165
+ for (const s of liveSteps) {
166
+ if (!groups.has(s.app)) groups.set(s.app, [])
167
+ groups.get(s.app)!.push(s)
168
+ }
169
+ return [...groups.entries()].sort(([a], [b]) => a.localeCompare(b))
170
+ }, [liveSteps])
171
+
172
+ return {
173
+ liveReachable,
174
+ liveUnreachable,
175
+ liveCatalog,
176
+ liveSteps,
177
+ liveContext,
178
+ validationIssues,
179
+ status,
180
+ refreshError,
181
+ widgetById,
182
+ propsSchemaByWidgetId,
183
+ paletteByApp,
184
+ liveKeyCount,
185
+ stepsByApp,
186
+ }
187
+ }
@@ -0,0 +1,60 @@
1
+ import { useCallback, useState } from "react"
2
+ import type { PipelineStepRef } from "@miragon/mcp-toolkit-core"
3
+ import { defaultStepIdFor, keysToEntries, type KeyEntry } from "./builder-model.js"
4
+
5
+ // -------------------------------------------------------------------------- //
6
+ // Keys + steps editor state — the pipeline tab's editable entry lists
7
+ // -------------------------------------------------------------------------- //
8
+
9
+ /**
10
+ * Owns the key/step editor entries and their mutations, extracted from
11
+ * `layout-builder.tsx` unchanged. `addKey` de-dupes a prefilled name (the
12
+ * catalogue's "Add as key" action) and `addStep` derives a unique context
13
+ * id via `defaultStepIdFor`.
14
+ */
15
+ export function useKeyStepEntries({
16
+ initialKeys,
17
+ initialSteps,
18
+ }: {
19
+ initialKeys?: Record<string, unknown>
20
+ initialSteps?: PipelineStepRef[]
21
+ }) {
22
+ const [keyEntries, setKeyEntries] = useState<KeyEntry[]>(() => keysToEntries(initialKeys))
23
+ const [stepEntries, setStepEntries] = useState<PipelineStepRef[]>(() => initialSteps ?? [])
24
+
25
+ const addKey = useCallback((prefilledName?: string) => {
26
+ setKeyEntries((prev) => {
27
+ if (prefilledName) {
28
+ if (prev.some((e) => e.name === prefilledName)) return prev
29
+ return [...prev, { name: prefilledName, rawValue: "" }]
30
+ }
31
+ return [...prev, { name: "", rawValue: "" }]
32
+ })
33
+ }, [])
34
+
35
+ const updateKey = useCallback((idx: number, patch: Partial<KeyEntry>) => {
36
+ setKeyEntries((prev) => prev.map((e, i) => (i === idx ? { ...e, ...patch } : e)))
37
+ }, [])
38
+
39
+ const removeKey = useCallback((idx: number) => {
40
+ setKeyEntries((prev) => prev.filter((_, i) => i !== idx))
41
+ }, [])
42
+
43
+ const addStep = useCallback((stepId?: string) => {
44
+ setStepEntries((prev) => {
45
+ const taken = new Set(prev.map((s) => s.id))
46
+ const newId = stepId ? defaultStepIdFor(stepId, taken) : `step-${prev.length + 1}`
47
+ return [...prev, { id: newId, step: stepId ?? "", optional: false }]
48
+ })
49
+ }, [])
50
+
51
+ const updateStep = useCallback((idx: number, patch: Partial<PipelineStepRef>) => {
52
+ setStepEntries((prev) => prev.map((s, i) => (i === idx ? { ...s, ...patch } : s)))
53
+ }, [])
54
+
55
+ const removeStep = useCallback((idx: number) => {
56
+ setStepEntries((prev) => prev.filter((_, i) => i !== idx))
57
+ }, [])
58
+
59
+ return { keyEntries, stepEntries, addKey, updateKey, removeKey, addStep, updateStep, removeStep }
60
+ }