@miragon/mcp-toolkit-ui 1.1.0 → 1.2.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
@@ -1,27 +1,7 @@
1
- import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react"
2
- import { AlertTriangle, Eye, Layers, Plus, SlidersHorizontal, X } from "lucide-react"
3
- import type {
4
- AvailableStep,
5
- KeyCatalogEntry,
6
- LayoutConfig,
7
- PipelineContext,
8
- PipelineStepRef,
9
- ReachableWidget,
10
- RowDef,
11
- UnreachableWidget,
12
- WidgetProps,
13
- } from "@miragon/mcp-toolkit-core"
14
- import { parseToolResult } from "../lib/parse-tool-result.js"
15
- import {
16
- defaultStepIdFor,
17
- draftToLayout,
18
- entriesToKeys,
19
- initDraft,
20
- keysToEntries,
21
- sizeToSpan,
22
- toPipelineContext,
23
- type KeyEntry,
24
- } from "./builder/builder-model.js"
1
+ import { useCallback, useMemo, useReducer, useState } from "react"
2
+ import { Eye, Layers, SlidersHorizontal } from "lucide-react"
3
+ import type { LayoutConfig, PipelineStepRef, RowDef, WidgetProps } from "@miragon/mcp-toolkit-core"
4
+ import { draftToLayout, entriesToKeys, initDraft, sizeToSpan } from "./builder/builder-model.js"
25
5
  import {
26
6
  draftReducer,
27
7
  initBuilderState,
@@ -30,14 +10,17 @@ import {
30
10
  import { DEFAULT_LABELS, type LayoutBuilderLabels } from "./builder/labels.js"
31
11
  import { Toolbar } from "./builder/Toolbar.js"
32
12
  import { PipelineStrip } from "./builder/PipelineStrip.js"
33
- import { Workspace } from "./builder/Workspace.js"
34
13
  import { CatalogueSheet } from "./builder/CatalogueSheet.js"
35
14
  import { SaveDialog } from "./builder/SaveDialog.js"
36
15
  import { PreviewPane } from "./builder/PreviewPane.js"
37
- import { WidgetPropsSheet } from "./widget-props-sheet.js"
38
- import { Button } from "../primitives/button.js"
16
+ import { LayoutSurface } from "./builder/LayoutSurface.js"
17
+ import { BuilderBanners } from "./builder/BuilderBanners.js"
18
+ import { EmptyHint } from "./builder/EmptyHint.js"
19
+ import { CellPropsSheet } from "./builder/CellPropsSheet.js"
20
+ import { useBuilderCatalogue } from "./builder/use-builder-catalogue.js"
21
+ import { useKeyStepEntries } from "./builder/use-key-step-entries.js"
22
+ import { useSaveDashboard } from "./builder/use-save-dashboard.js"
39
23
  import { Tabs, TabsContent, TabsList, TabsTrigger } from "../primitives/tabs.js"
40
- import { cn } from "../lib/utils.js"
41
24
  import { type WidgetComponent } from "./widget-renderer.js"
42
25
 
43
26
  // -------------------------------------------------------------------------- //
@@ -90,8 +73,6 @@ export interface LayoutBuilderProps {
90
73
  // Main component
91
74
  // -------------------------------------------------------------------------- //
92
75
 
93
- const EMPTY_CONTEXT: PipelineContext = { steps: {}, keys: {}, errors: [] }
94
-
95
76
  export function LayoutBuilder({
96
77
  initialLayout,
97
78
  title,
@@ -152,38 +133,50 @@ export function LayoutBuilder({
152
133
  }
153
134
 
154
135
  // ── Keys + steps editor state ───────────────────────────────────────────
155
- const [keyEntries, setKeyEntries] = useState<KeyEntry[]>(() => keysToEntries(initialKeys))
156
- const [stepEntries, setStepEntries] = useState<PipelineStepRef[]>(() => initialSteps ?? [])
157
-
158
- // ── Live snapshots from get-builder-catalogue responses ─────────────────
159
- // Start empty — the first effect below fetches the catalogue on mount.
160
- const [liveReachable, setLiveReachable] = useState<ReachableWidget[]>([])
161
- const [liveUnreachable, setLiveUnreachable] = useState<UnreachableWidget[]>([])
162
- const [liveCatalog, setLiveCatalog] = useState<KeyCatalogEntry[]>([])
163
- const [liveSteps, setLiveSteps] = useState<AvailableStep[]>([])
164
- const [liveContext, setLiveContext] = useState<PipelineContext>(EMPTY_CONTEXT)
165
- // Pipeline validation issues from the catalogue (Finding [10]/[6]). The
166
- // catalogue is fail-soft, but `render-view` is fail-hard, so we surface
167
- // these so the user sees — before committing — that the pipeline as
168
- // configured wouldn't render.
169
- const [validationIssues, setValidationIssues] = useState<string[]>([])
170
-
171
- // ── Refresh status (for the auto-apply chip) ────────────────────────────
172
- type RefreshStatus = "idle" | "pending" | "refreshing"
173
- const [status, setStatus] = useState<RefreshStatus>("idle")
174
- // Last catalogue-fetch failure (Finding [3]). Surfaced as a toolbar banner
175
- // instead of only `console.warn`-ing, so the user knows the palette/context
176
- // may be stale. Cleared on the next successful refresh.
177
- const [refreshError, setRefreshError] = useState<string | null>(null)
136
+ const { keyEntries, stepEntries, addKey, updateKey, removeKey, addStep, updateStep, removeStep } =
137
+ useKeyStepEntries({ initialKeys, initialSteps })
138
+
139
+ // ── Catalogue fetch (mount + on key/step edits) ─────────────────────────
140
+ // The builder fetches its own catalogue from the app-only
141
+ // `get-builder-catalogue` tool; the LLM never sees this data.
142
+ const {
143
+ liveReachable,
144
+ liveUnreachable,
145
+ liveCatalog,
146
+ liveSteps,
147
+ liveContext,
148
+ validationIssues,
149
+ status,
150
+ refreshError,
151
+ widgetById,
152
+ propsSchemaByWidgetId,
153
+ paletteByApp,
154
+ liveKeyCount,
155
+ stepsByApp,
156
+ } = useBuilderCatalogue({ callTool, catalogueToolName, keyEntries, stepEntries })
178
157
 
179
158
  // ── Save dialog ─────────────────────────────────────────────────────────
180
- const [isBusy, setBusy] = useState(false)
181
- const [saveOpen, setSaveOpen] = useState(false)
182
- const [saveName, setSaveName] = useState(title ?? "")
183
- const [saveDescription, setSaveDescription] = useState("")
184
- // Last save failure (Finding [3]). Shown inline in the SaveDialog so a
185
- // rejected `save-dashboard` call no longer fails silently.
186
- const [saveError, setSaveError] = useState<string | null>(null)
159
+ const {
160
+ isBusy,
161
+ saveOpen,
162
+ setSaveOpen,
163
+ saveName,
164
+ setSaveName,
165
+ saveDescription,
166
+ setSaveDescription,
167
+ saveError,
168
+ setSaveError,
169
+ saveDraft,
170
+ } = useSaveDashboard({
171
+ callTool,
172
+ saveToolName,
173
+ dashboardId,
174
+ title,
175
+ keyEntries,
176
+ stepEntries,
177
+ draft,
178
+ onSaved,
179
+ })
187
180
 
188
181
  // ── DnD ──────────────────────────────────────────────────────────────────
189
182
  const [dragOverRow, setDragOverRow] = useState<number | null>(null)
@@ -198,48 +191,6 @@ export function LayoutBuilder({
198
191
  cellIdx: number
199
192
  } | null>(null)
200
193
 
201
- // ── Derived ─────────────────────────────────────────────────────────────
202
- const widgetById = useMemo(() => {
203
- const map = new Map<string, ReachableWidget>()
204
- for (const w of liveReachable) map.set(w.id, w)
205
- return map
206
- }, [liveReachable])
207
-
208
- // Per-widget propsSchema lookup. Merged from both reachable and
209
- // unreachable lists because a saved layout can reference a widget whose
210
- // `requires` aren't currently satisfied — we still want the user to be
211
- // able to edit its props.
212
- const propsSchemaByWidgetId = useMemo(() => {
213
- const map = new Map<string, Record<string, unknown>>()
214
- for (const w of liveReachable) {
215
- if (w.propsSchema) map.set(w.id, w.propsSchema)
216
- }
217
- for (const w of liveUnreachable) {
218
- if (w.propsSchema) map.set(w.id, w.propsSchema)
219
- }
220
- return map
221
- }, [liveReachable, liveUnreachable])
222
-
223
- const paletteByApp = useMemo(() => {
224
- const groups = new Map<string, ReachableWidget[]>()
225
- for (const w of liveReachable) {
226
- if (!groups.has(w.app)) groups.set(w.app, [])
227
- groups.get(w.app)!.push(w)
228
- }
229
- return [...groups.entries()].sort(([a], [b]) => a.localeCompare(b))
230
- }, [liveReachable])
231
-
232
- const liveKeyCount = useMemo(() => liveCatalog.filter((c) => c.inContext).length, [liveCatalog])
233
-
234
- const stepsByApp = useMemo(() => {
235
- const groups = new Map<string, AvailableStep[]>()
236
- for (const s of liveSteps) {
237
- if (!groups.has(s.app)) groups.set(s.app, [])
238
- groups.get(s.app)!.push(s)
239
- }
240
- return [...groups.entries()].sort(([a], [b]) => a.localeCompare(b))
241
- }, [liveSteps])
242
-
243
194
  const activeRows: RowDef[] =
244
195
  draft.kind === "tabs" ? (draft.tabs[activeTabIndex]?.rows ?? []) : draft.rows
245
196
 
@@ -311,164 +262,6 @@ export function LayoutBuilder({
311
262
 
312
263
  const removeTab = useCallback((tabIdx: number) => dispatch({ type: "removeTab", tabIdx }), [])
313
264
 
314
- // ── Keys/steps mutations ────────────────────────────────────────────────
315
- const addKey = useCallback((prefilledName?: string) => {
316
- setKeyEntries((prev) => {
317
- if (prefilledName) {
318
- if (prev.some((e) => e.name === prefilledName)) return prev
319
- return [...prev, { name: prefilledName, rawValue: "" }]
320
- }
321
- return [...prev, { name: "", rawValue: "" }]
322
- })
323
- }, [])
324
-
325
- const updateKey = useCallback((idx: number, patch: Partial<KeyEntry>) => {
326
- setKeyEntries((prev) => prev.map((e, i) => (i === idx ? { ...e, ...patch } : e)))
327
- }, [])
328
-
329
- const removeKey = useCallback((idx: number) => {
330
- setKeyEntries((prev) => prev.filter((_, i) => i !== idx))
331
- }, [])
332
-
333
- const addStep = useCallback((stepId?: string) => {
334
- setStepEntries((prev) => {
335
- const taken = new Set(prev.map((s) => s.id))
336
- const newId = stepId ? defaultStepIdFor(stepId, taken) : `step-${prev.length + 1}`
337
- return [...prev, { id: newId, step: stepId ?? "", optional: false }]
338
- })
339
- }, [])
340
-
341
- const updateStep = useCallback((idx: number, patch: Partial<PipelineStepRef>) => {
342
- setStepEntries((prev) => prev.map((s, i) => (i === idx ? { ...s, ...patch } : s)))
343
- }, [])
344
-
345
- const removeStep = useCallback((idx: number) => {
346
- setStepEntries((prev) => prev.filter((_, i) => i !== idx))
347
- }, [])
348
-
349
- // ── Catalogue fetch (mount + on key/step edits) ─────────────────────────
350
- // The builder fetches its own catalogue from the app-only
351
- // `get-builder-catalogue` tool; the LLM never sees this data.
352
- const refreshBuilder = useCallback(async () => {
353
- setStatus("refreshing")
354
- try {
355
- const result = await callTool(catalogueToolName, {
356
- keys: entriesToKeys(keyEntries),
357
- steps: stepEntries.filter((s) => s.step.trim().length > 0),
358
- })
359
- const sc = parseToolResult<{
360
- reachableWidgets?: ReachableWidget[]
361
- unreachableWidgets?: UnreachableWidget[]
362
- availableSteps?: AvailableStep[]
363
- keyCatalog?: KeyCatalogEntry[]
364
- validationIssues?: string[]
365
- context?: {
366
- keys: Record<string, unknown>
367
- stepIds: string[]
368
- stepData: Record<
369
- string,
370
- { data: unknown; keys: Record<string, unknown>; _app: string; _dataType: string }
371
- >
372
- errors: { stepId: string; reason: string }[]
373
- }
374
- } | null>(result)
375
- if (sc?.reachableWidgets) setLiveReachable(sc.reachableWidgets)
376
- if (sc?.unreachableWidgets) setLiveUnreachable(sc.unreachableWidgets)
377
- if (sc?.availableSteps) setLiveSteps(sc.availableSteps)
378
- if (sc?.keyCatalog) setLiveCatalog(sc.keyCatalog)
379
- if (sc?.context) setLiveContext(toPipelineContext(sc.context))
380
- // Pipeline issues are fail-soft in the catalogue but fail-hard in
381
- // render-view; surface them so the user sees the render path wouldn't
382
- // run as configured (Finding [10]/[6]). Coerce undefined → [] so a
383
- // fixed pipeline clears a previously-shown warning.
384
- setValidationIssues(sc?.validationIssues ?? [])
385
- // A successful fetch clears any stale failure banner.
386
- setRefreshError(null)
387
- } catch (err) {
388
- // Fail soft: when the host's server hasn't registered the app-only
389
- // `get-builder-catalogue` tool (the builder platform is opt-in via
390
- // `app.builder`), the call rejects. Degrade to the static catalogue
391
- // already derived from the layout rather than crashing the iframe —
392
- // but surface the failure so the user knows the palette may be stale
393
- // (Finding [3]).
394
- const reason = err instanceof Error ? err.message : String(err)
395
- console.warn(
396
- `[mcp-toolkit] builder catalogue fetch failed (${catalogueToolName}): ${reason}. ` +
397
- "The builder platform may be disabled on this server (app.builder).",
398
- )
399
- setRefreshError(reason)
400
- } finally {
401
- setStatus("idle")
402
- }
403
- }, [catalogueToolName, callTool, keyEntries, stepEntries])
404
-
405
- // Fetch the catalogue on mount (no debounce) so the builder is ready
406
- // immediately. Then on every keys/steps edit, debounce 600ms before
407
- // re-fetching. The ref pattern keeps the effect dependent on only
408
- // keyEntries / stepEntries.
409
- const refreshRef = useRef(refreshBuilder)
410
- useEffect(() => {
411
- refreshRef.current = refreshBuilder
412
- }, [refreshBuilder])
413
- const isFirstRun = useRef(true)
414
- useEffect(() => {
415
- if (isFirstRun.current) {
416
- isFirstRun.current = false
417
- void refreshRef.current()
418
- return
419
- }
420
- setStatus("pending")
421
- const handle = window.setTimeout(() => {
422
- void refreshRef.current()
423
- }, 600)
424
- return () => {
425
- window.clearTimeout(handle)
426
- }
427
- }, [keyEntries, stepEntries])
428
-
429
- // ── Save ────────────────────────────────────────────────────────────────
430
- const saveDraft = useCallback(async () => {
431
- if (!saveName.trim()) return
432
- setBusy(true)
433
- setSaveError(null)
434
- try {
435
- const result = await callTool(saveToolName, {
436
- id: dashboardId,
437
- name: saveName.trim(),
438
- description: saveDescription.trim() || undefined,
439
- keys: entriesToKeys(keyEntries),
440
- steps: stepEntries.filter((s) => s.step.trim().length > 0),
441
- layout: draftToLayout(draft),
442
- title,
443
- })
444
- const saved = parseToolResult<{ id?: string; name?: string } | null>(result)
445
- setSaveOpen(false)
446
- if (saved?.id && onSaved) {
447
- onSaved({
448
- id: saved.id,
449
- name: saved.name ?? saveName.trim(),
450
- })
451
- }
452
- } catch (err) {
453
- // Previously uncaught: a rejected `save-dashboard` left the dialog open
454
- // with no feedback. Surface the reason inline (Finding [3]).
455
- setSaveError(err instanceof Error ? err.message : String(err))
456
- } finally {
457
- setBusy(false)
458
- }
459
- }, [
460
- callTool,
461
- dashboardId,
462
- draft,
463
- keyEntries,
464
- onSaved,
465
- saveDescription,
466
- saveName,
467
- saveToolName,
468
- stepEntries,
469
- title,
470
- ])
471
-
472
265
  // ── Smart action: add a producing step for a missing key ────────────────
473
266
  const addProducingStepFor = useCallback(
474
267
  (key: string) => {
@@ -486,140 +279,6 @@ export function LayoutBuilder({
486
279
  const hasAnyCell = activeRows.some((r) => r.row.length > 0)
487
280
  const liveKeysMap = liveContext.keys
488
281
 
489
- const layoutContent =
490
- draft.kind === "tabs" ? (
491
- <Tabs
492
- value={draft.tabs[activeTabIndex]?.label ?? ""}
493
- onValueChange={(label) => {
494
- const idx = draft.tabs.findIndex((t) => t.label === label)
495
- if (idx >= 0) setActiveTabIndex(idx)
496
- }}
497
- >
498
- <div className="mb-3 flex items-center gap-1">
499
- <TabsList>
500
- {draft.tabs.map((tab, idx) => (
501
- <div key={tab.label} className="group relative inline-flex">
502
- <TabsTrigger
503
- value={tab.label}
504
- className="pr-7"
505
- onDoubleClick={() => setEditingTabIdx(idx)}
506
- title="Double-click to rename"
507
- >
508
- {editingTabIdx === idx ? (
509
- <input
510
- autoFocus
511
- defaultValue={tab.label}
512
- aria-invalid={renameRejected}
513
- onClick={(e) => e.stopPropagation()}
514
- onPointerDown={(e) => e.stopPropagation()}
515
- onKeyDown={(e) => {
516
- if (e.key === "Enter") {
517
- e.preventDefault()
518
- commitRename(idx, e.currentTarget.value)
519
- } else if (e.key === "Escape") {
520
- e.preventDefault()
521
- setEditingTabIdx(null)
522
- }
523
- }}
524
- onBlur={(e) => {
525
- commitRename(idx, e.currentTarget.value)
526
- }}
527
- className={cn(
528
- "w-24 border-none bg-transparent p-0 text-inherit outline-none focus:ring-0",
529
- renameRejected && "text-destructive",
530
- )}
531
- size={Math.max(tab.label.length + 1, 6)}
532
- />
533
- ) : (
534
- <span>{tab.label}</span>
535
- )}
536
- </TabsTrigger>
537
- <button
538
- type="button"
539
- aria-label={L.removeTab}
540
- title={L.removeTab}
541
- onPointerDown={(e) => e.stopPropagation()}
542
- onClick={(e) => {
543
- e.stopPropagation()
544
- removeTab(idx)
545
- }}
546
- className="text-muted-foreground/60 hover:text-foreground absolute top-1/2 right-1 -translate-y-1/2 rounded p-0.5 opacity-0 transition-opacity group-hover:opacity-100 data-[state=active]:opacity-100"
547
- >
548
- <X className="size-3" />
549
- </button>
550
- </div>
551
- ))}
552
- </TabsList>
553
- <Button
554
- variant="ghost"
555
- size="icon-sm"
556
- onClick={addTab}
557
- aria-label={L.addTab}
558
- title={L.addTab}
559
- >
560
- <Plus />
561
- </Button>
562
- <span className="text-muted-foreground/70 ml-2 hidden text-[10px] sm:inline">
563
- double-click a tab to rename
564
- </span>
565
- </div>
566
- {renameRejected && editingTabIdx !== null && (
567
- <p className="text-destructive -mt-1 mb-2 text-[11px]" role="alert">
568
- {L.renameDuplicate}
569
- </p>
570
- )}
571
- {draft.tabs.map((tab, idx) => (
572
- <TabsContent key={tab.label} value={tab.label} className="mt-0">
573
- {idx === activeTabIndex ? (
574
- <Workspace
575
- L={L}
576
- paletteByApp={paletteByApp}
577
- paletteCount={liveReachable.length}
578
- activeRows={activeRows}
579
- focusedRowIndex={focusedRowIndex}
580
- setFocusedRowIndex={setFocusedRowIndex}
581
- dragOverRow={dragOverRow}
582
- setDragOverRow={setDragOverRow}
583
- onAddRow={addRow}
584
- onRemoveRow={removeRow}
585
- onMoveRow={moveRow}
586
- onSetCellSpan={setCellSpan}
587
- onRemoveCell={removeCell}
588
- onAddWidget={addWidgetToRow}
589
- onConfigureCell={(rowIdx, cellIdx) => setConfiguringCell({ rowIdx, cellIdx })}
590
- propsSchemaByWidgetId={propsSchemaByWidgetId}
591
- widgets={widgets}
592
- widgetProps={widgetProps}
593
- />
594
- ) : null}
595
- </TabsContent>
596
- ))}
597
- </Tabs>
598
- ) : (
599
- <Workspace
600
- L={L}
601
- paletteByApp={paletteByApp}
602
- paletteCount={liveReachable.length}
603
- activeRows={activeRows}
604
- focusedRowIndex={focusedRowIndex}
605
- setFocusedRowIndex={setFocusedRowIndex}
606
- dragOverRow={dragOverRow}
607
- setDragOverRow={setDragOverRow}
608
- onAddRow={addRow}
609
- onRemoveRow={removeRow}
610
- onMoveRow={moveRow}
611
- onSetCellSpan={setCellSpan}
612
- onRemoveCell={removeCell}
613
- onAddWidget={addWidgetToRow}
614
- onConfigureCell={(rowIdx, cellIdx) => setConfiguringCell({ rowIdx, cellIdx })}
615
- propsSchemaByWidgetId={propsSchemaByWidgetId}
616
- widgets={widgets}
617
- widgetProps={widgetProps}
618
- showAddTabButton
619
- onAddTab={addTab}
620
- />
621
- )
622
-
623
282
  return (
624
283
  <div className="flex min-h-[60vh] flex-col gap-4 p-1">
625
284
  <Toolbar
@@ -694,7 +353,38 @@ export function LayoutBuilder({
694
353
  {liveReachable.length === 0 && !hasAnyCell ? (
695
354
  <EmptyHint L={L} onGotoPipeline={() => setView("pipeline")} />
696
355
  ) : (
697
- layoutContent
356
+ <LayoutSurface
357
+ L={L}
358
+ draft={draft}
359
+ activeTabIndex={activeTabIndex}
360
+ setActiveTabIndex={setActiveTabIndex}
361
+ editingTabIdx={editingTabIdx}
362
+ setEditingTabIdx={setEditingTabIdx}
363
+ renameRejected={renameRejected}
364
+ commitRename={commitRename}
365
+ onAddTab={addTab}
366
+ onRemoveTab={removeTab}
367
+ workspace={{
368
+ L,
369
+ paletteByApp,
370
+ paletteCount: liveReachable.length,
371
+ activeRows,
372
+ focusedRowIndex,
373
+ setFocusedRowIndex,
374
+ dragOverRow,
375
+ setDragOverRow,
376
+ onAddRow: addRow,
377
+ onRemoveRow: removeRow,
378
+ onMoveRow: moveRow,
379
+ onSetCellSpan: setCellSpan,
380
+ onRemoveCell: removeCell,
381
+ onAddWidget: addWidgetToRow,
382
+ onConfigureCell: (rowIdx, cellIdx) => setConfiguringCell({ rowIdx, cellIdx }),
383
+ propsSchemaByWidgetId,
384
+ widgets,
385
+ widgetProps,
386
+ }}
387
+ />
698
388
  )}
699
389
  </TabsContent>
700
390
 
@@ -765,108 +455,13 @@ export function LayoutBuilder({
765
455
  error={saveError}
766
456
  />
767
457
 
768
- {(() => {
769
- if (!configuringCell) return null
770
- const cell = activeRows[configuringCell.rowIdx]?.row[configuringCell.cellIdx]
771
- if (!cell) return null
772
- const schema = propsSchemaByWidgetId.get(cell.widget)
773
- return (
774
- <WidgetPropsSheet
775
- open
776
- onOpenChange={(open) => {
777
- if (!open) setConfiguringCell(null)
778
- }}
779
- widgetId={cell.widget}
780
- schema={schema}
781
- value={cell.props}
782
- onApply={(next) => {
783
- setCellProps(configuringCell.rowIdx, configuringCell.cellIdx, next)
784
- setConfiguringCell(null)
785
- }}
786
- />
787
- )
788
- })()}
789
- </div>
790
- )
791
- }
792
-
793
- function EmptyHint({
794
- L,
795
- onGotoPipeline,
796
- }: {
797
- L: Required<LayoutBuilderLabels>
798
- onGotoPipeline: () => void
799
- }) {
800
- return (
801
- <div className="border-muted-foreground/30 text-muted-foreground flex flex-col items-center gap-2 rounded-md border border-dashed p-10 text-sm">
802
- <Layers className="size-5" />
803
- <p>No widgets reachable yet.</p>
804
- <p className="text-xs">
805
- Open the <span className="font-medium">{L.viewPipelineTab}</span> tab to seed keys or add a
806
- pipeline step — the palette unlocks as soon as a widget's contract is satisfied.
807
- </p>
808
- <Button variant="outline" size="sm" onClick={onGotoPipeline} className="mt-1">
809
- <SlidersHorizontal /> Open {L.viewPipelineTab}
810
- </Button>
811
- </div>
812
- )
813
- }
814
-
815
- // -------------------------------------------------------------------------- //
816
- // Builder banners — catalogue-fetch failure + pipeline validation warning
817
- // -------------------------------------------------------------------------- //
818
-
819
- /**
820
- * Inline feedback strip under the toolbar. Surfaces two things the builder
821
- * previously swallowed:
822
- * - a `get-builder-catalogue` fetch failure (Finding [3]) — only console-
823
- * logged before, so the user couldn't tell the palette was stale, and
824
- * - pipeline validation issues (Finding [10]/[6]) the catalogue reports
825
- * fail-soft but `render-view` would reject fail-hard.
826
- *
827
- * Renders nothing when there's no error and no issues, so the happy path is
828
- * visually unchanged.
829
- */
830
- function BuilderBanners({
831
- L,
832
- catalogueToolName,
833
- refreshError,
834
- validationIssues,
835
- }: {
836
- L: Required<LayoutBuilderLabels>
837
- catalogueToolName: string
838
- refreshError: string | null
839
- validationIssues: string[]
840
- }) {
841
- if (!refreshError && validationIssues.length === 0) return null
842
- return (
843
- <div className="flex flex-col gap-2" role="status" aria-live="polite">
844
- {refreshError && (
845
- <div className="border-destructive/30 bg-destructive/10 text-destructive flex items-start gap-2 rounded-md border px-3 py-2 text-xs">
846
- <AlertTriangle className="mt-0.5 size-3.5 shrink-0" aria-hidden="true" />
847
- <div className="min-w-0">
848
- <p className="font-medium">{L.catalogueError}</p>
849
- <p className="text-destructive/80 mt-0.5 break-words">
850
- <span className="font-mono">{catalogueToolName}</span>: {refreshError}
851
- </p>
852
- </div>
853
- </div>
854
- )}
855
- {validationIssues.length > 0 && (
856
- <div className="flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-400">
857
- <AlertTriangle className="mt-0.5 size-3.5 shrink-0" aria-hidden="true" />
858
- <div className="min-w-0">
859
- <p className="font-medium">{L.validationWarning}</p>
860
- <ul className="mt-0.5 list-inside list-disc space-y-0.5">
861
- {validationIssues.map((issue, idx) => (
862
- <li key={idx} className="break-words">
863
- {issue}
864
- </li>
865
- ))}
866
- </ul>
867
- </div>
868
- </div>
869
- )}
458
+ <CellPropsSheet
459
+ configuringCell={configuringCell}
460
+ activeRows={activeRows}
461
+ propsSchemaByWidgetId={propsSchemaByWidgetId}
462
+ onApply={setCellProps}
463
+ onClose={() => setConfiguringCell(null)}
464
+ />
870
465
  </div>
871
466
  )
872
467
  }