@object-ui/app-shell 7.0.0 → 7.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 (141) hide show
  1. package/CHANGELOG.md +560 -0
  2. package/dist/console/AppContent.js +23 -17
  3. package/dist/console/ConsoleShell.d.ts +16 -0
  4. package/dist/console/ConsoleShell.js +43 -2
  5. package/dist/console/ai/AiChatPage.js +47 -16
  6. package/dist/console/ai/LiveCanvas.d.ts +8 -2
  7. package/dist/console/ai/LiveCanvas.js +6 -4
  8. package/dist/console/home/HomeLayout.js +5 -7
  9. package/dist/console/home/HomePage.js +1 -9
  10. package/dist/console/organizations/CreateWorkspaceDialog.js +15 -1
  11. package/dist/console/organizations/OrganizationsPage.js +22 -3
  12. package/dist/console/organizations/provisionEnvironment.d.ts +53 -0
  13. package/dist/console/organizations/provisionEnvironment.js +64 -0
  14. package/dist/environment/EnvironmentEntitlementDialog.d.ts +34 -0
  15. package/dist/environment/EnvironmentEntitlementDialog.js +37 -0
  16. package/dist/environment/EnvironmentListToolbar.d.ts +33 -0
  17. package/dist/environment/EnvironmentListToolbar.js +59 -0
  18. package/dist/environment/entitlements.d.ts +90 -0
  19. package/dist/environment/entitlements.js +91 -0
  20. package/dist/environment/useEnvironmentEntitlements.d.ts +32 -0
  21. package/dist/environment/useEnvironmentEntitlements.js +108 -0
  22. package/dist/hooks/useActionModal.js +15 -1
  23. package/dist/hooks/useAiSurface.d.ts +59 -0
  24. package/dist/hooks/useAiSurface.js +78 -0
  25. package/dist/hooks/useChatConversation.d.ts +30 -0
  26. package/dist/hooks/useChatConversation.js +63 -0
  27. package/dist/hooks/useConsoleActionRuntime.d.ts +3 -0
  28. package/dist/hooks/useConsoleActionRuntime.js +42 -10
  29. package/dist/index.d.ts +5 -2
  30. package/dist/index.js +10 -2
  31. package/dist/layout/AppHeader.js +28 -4
  32. package/dist/layout/ConsoleFloatingChatbot.d.ts +6 -4
  33. package/dist/layout/ConsoleFloatingChatbot.js +41 -10
  34. package/dist/layout/ConsoleLayout.js +5 -6
  35. package/dist/layout/ContextSelectors.js +59 -35
  36. package/dist/layout/agentPicker.d.ts +56 -0
  37. package/dist/layout/agentPicker.js +40 -0
  38. package/dist/preview/CommitTimeline.d.ts +15 -0
  39. package/dist/preview/CommitTimeline.js +82 -0
  40. package/dist/preview/DraftPreviewBar.js +20 -7
  41. package/dist/preview/UnpublishedAppBar.js +11 -7
  42. package/dist/preview/commitHistory.d.ts +28 -0
  43. package/dist/preview/commitHistory.js +48 -0
  44. package/dist/providers/ExpressionProvider.js +9 -3
  45. package/dist/providers/MetadataProvider.js +9 -0
  46. package/dist/utils/index.d.ts +2 -2
  47. package/dist/utils/index.js +1 -1
  48. package/dist/utils/recordFormNavigation.d.ts +60 -0
  49. package/dist/utils/recordFormNavigation.js +35 -0
  50. package/dist/utils/resolvePageVarTokens.d.ts +31 -0
  51. package/dist/utils/resolvePageVarTokens.js +72 -0
  52. package/dist/views/CreateViewDialog.js +14 -1
  53. package/dist/views/FlowRunner.d.ts +2 -30
  54. package/dist/views/FlowRunner.js +18 -50
  55. package/dist/views/ObjectView.js +26 -12
  56. package/dist/views/ScreenView.d.ts +70 -0
  57. package/dist/views/ScreenView.js +73 -0
  58. package/dist/views/metadata-admin/AssignedUsersSection.d.ts +28 -0
  59. package/dist/views/metadata-admin/AssignedUsersSection.js +151 -0
  60. package/dist/views/metadata-admin/DirectoryPage.js +2 -14
  61. package/dist/views/metadata-admin/JsonSourceEditor.d.ts +3 -1
  62. package/dist/views/metadata-admin/JsonSourceEditor.js +21 -3
  63. package/dist/views/metadata-admin/PackagesPage.d.ts +5 -0
  64. package/dist/views/metadata-admin/PackagesPage.js +58 -5
  65. package/dist/views/metadata-admin/PermissionMatrixEditor.js +2 -1
  66. package/dist/views/metadata-admin/ResourceEditPage.js +83 -24
  67. package/dist/views/metadata-admin/ResourceListPage.js +28 -19
  68. package/dist/views/metadata-admin/StudioHomePage.js +6 -14
  69. package/dist/views/metadata-admin/anchors.js +20 -2
  70. package/dist/views/metadata-admin/createBody.d.ts +26 -0
  71. package/dist/views/metadata-admin/createBody.js +30 -0
  72. package/dist/views/metadata-admin/i18n.js +108 -2
  73. package/dist/views/metadata-admin/inspectors/DatasetDefaultInspector.d.ts +10 -2
  74. package/dist/views/metadata-admin/inspectors/DatasetDefaultInspector.js +136 -8
  75. package/dist/views/metadata-admin/inspectors/FlowEdgeInspector.js +99 -4
  76. package/dist/views/metadata-admin/inspectors/FlowExprIssue.d.ts +21 -0
  77. package/dist/views/metadata-admin/inspectors/FlowExprIssue.js +13 -0
  78. package/dist/views/metadata-admin/inspectors/FlowKeyValueField.d.ts +20 -2
  79. package/dist/views/metadata-admin/inspectors/FlowKeyValueField.js +71 -28
  80. package/dist/views/metadata-admin/inspectors/FlowNodeConfigField.d.ts +4 -1
  81. package/dist/views/metadata-admin/inspectors/FlowNodeConfigField.js +24 -9
  82. package/dist/views/metadata-admin/inspectors/FlowNodeInspector.js +81 -4
  83. package/dist/views/metadata-admin/inspectors/FlowObjectListField.d.ts +4 -1
  84. package/dist/views/metadata-admin/inspectors/FlowObjectListField.js +8 -3
  85. package/dist/views/metadata-admin/inspectors/ObjectDefaultInspector.js +5 -4
  86. package/dist/views/metadata-admin/inspectors/ObjectFieldInspector.js +47 -12
  87. package/dist/views/metadata-admin/inspectors/ReportDefaultInspector.d.ts +1 -1
  88. package/dist/views/metadata-admin/inspectors/ReportDefaultInspector.js +60 -2
  89. package/dist/views/metadata-admin/inspectors/VariableTextInput.d.ts +47 -0
  90. package/dist/views/metadata-admin/inspectors/VariableTextInput.js +95 -0
  91. package/dist/views/metadata-admin/inspectors/_shared.d.ts +5 -1
  92. package/dist/views/metadata-admin/inspectors/_shared.js +2 -2
  93. package/dist/views/metadata-admin/inspectors/datasetFilterCondition.d.ts +24 -0
  94. package/dist/views/metadata-admin/inspectors/datasetFilterCondition.js +102 -0
  95. package/dist/views/metadata-admin/inspectors/flow-node-config.d.ts +16 -1
  96. package/dist/views/metadata-admin/inspectors/flow-node-config.js +67 -11
  97. package/dist/views/metadata-admin/inspectors/flow-ref-check.d.ts +39 -0
  98. package/dist/views/metadata-admin/inspectors/flow-ref-check.js +114 -0
  99. package/dist/views/metadata-admin/inspectors/flow-scope.d.ts +109 -0
  100. package/dist/views/metadata-admin/inspectors/flow-scope.js +199 -0
  101. package/dist/views/metadata-admin/inspectors/useDatasetFields.d.ts +14 -3
  102. package/dist/views/metadata-admin/inspectors/useDatasetFields.js +0 -0
  103. package/dist/views/metadata-admin/inspectors/useFlowScope.d.ts +23 -0
  104. package/dist/views/metadata-admin/inspectors/useFlowScope.js +45 -0
  105. package/dist/views/metadata-admin/issuePath.d.ts +22 -0
  106. package/dist/views/metadata-admin/issuePath.js +65 -0
  107. package/dist/views/metadata-admin/package-scope.d.ts +41 -0
  108. package/dist/views/metadata-admin/package-scope.js +59 -0
  109. package/dist/views/metadata-admin/preview-registry.d.ts +12 -0
  110. package/dist/views/metadata-admin/previews/DatasetPreview.js +21 -5
  111. package/dist/views/metadata-admin/previews/FlowCanvas.d.ts +26 -1
  112. package/dist/views/metadata-admin/previews/FlowCanvas.js +143 -16
  113. package/dist/views/metadata-admin/previews/FlowPreview.d.ts +1 -1
  114. package/dist/views/metadata-admin/previews/FlowPreview.js +47 -7
  115. package/dist/views/metadata-admin/previews/FlowSimulatorPanel.js +37 -3
  116. package/dist/views/metadata-admin/previews/ObjectFormCanvas.js +9 -4
  117. package/dist/views/metadata-admin/previews/PagePreview.js +112 -3
  118. package/dist/views/metadata-admin/previews/ProblemsPanel.d.ts +18 -0
  119. package/dist/views/metadata-admin/previews/ProblemsPanel.js +27 -0
  120. package/dist/views/metadata-admin/previews/ReportPreview.d.ts +9 -8
  121. package/dist/views/metadata-admin/previews/ReportPreview.js +33 -16
  122. package/dist/views/metadata-admin/previews/ScreenPreview.d.ts +38 -0
  123. package/dist/views/metadata-admin/previews/ScreenPreview.js +61 -0
  124. package/dist/views/metadata-admin/previews/flow-canvas-layout.d.ts +14 -0
  125. package/dist/views/metadata-admin/previews/flow-canvas-layout.js +37 -0
  126. package/dist/views/metadata-admin/previews/flow-canvas-parts.d.ts +17 -1
  127. package/dist/views/metadata-admin/previews/flow-canvas-parts.js +23 -6
  128. package/dist/views/metadata-admin/previews/flow-expr-problems.d.ts +19 -0
  129. package/dist/views/metadata-admin/previews/flow-expr-problems.js +97 -0
  130. package/dist/views/metadata-admin/previews/flow-problems.d.ts +84 -0
  131. package/dist/views/metadata-admin/previews/flow-problems.js +209 -0
  132. package/dist/views/metadata-admin/previews/object-fields-io.d.ts +21 -0
  133. package/dist/views/metadata-admin/previews/object-fields-io.js +37 -2
  134. package/dist/views/metadata-admin/previews/screen-spec.d.ts +43 -0
  135. package/dist/views/metadata-admin/previews/screen-spec.js +108 -0
  136. package/dist/views/metadata-admin/previews/simulator/flow-sim-types.d.ts +20 -0
  137. package/dist/views/metadata-admin/previews/simulator/flow-sim-validate.d.ts +7 -0
  138. package/dist/views/metadata-admin/previews/simulator/flow-sim-validate.js +76 -2
  139. package/dist/views/metadata-admin/previews/simulator/flow-simulator.d.ts +32 -3
  140. package/dist/views/metadata-admin/previews/simulator/flow-simulator.js +119 -9
  141. package/package.json +38 -38
@@ -21,17 +21,18 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
21
21
  * `onPatch(partial)` and the host merges + persists.
22
22
  */
23
23
  import * as React from 'react';
24
- import { Maximize2, Plus, ZoomIn, ZoomOut } from 'lucide-react';
24
+ import { AlertCircle, AlertTriangle, Maximize2, Plus, ZoomIn, ZoomOut } from 'lucide-react';
25
25
  import { cn } from '@object-ui/components';
26
26
  import { uniqueId, appendArray, spliceArray } from '../inspectors/_shared';
27
27
  import { t as tr } from '../i18n';
28
- import { computeLayout, diagramSize, bottomAnchor, topAnchor, edgePath, edgeMidpoint, edgeKey, conditionText, } from './flow-canvas-layout';
28
+ import { computeLayout, diagramSize, NODE_W, NODE_H, bottomAnchor, topAnchor, rightAnchor, edgePath, edgeMidpoint, backEdgePath, backEdgeLabelAnchor, isBackEdge, edgeKey, conditionText, } from './flow-canvas-layout';
29
29
  import { NodeCard, NodePalette, defaultNodeLabel, defaultNodeExtras } from './flow-canvas-parts';
30
30
  import { useFlowNodePalette } from './useFlowNodePalette';
31
+ import { indexProblemBadges, edgeProblemKey } from './flow-problems';
31
32
  const MIN_ZOOM = 0.4;
32
33
  const MAX_ZOOM = 1.6;
33
34
  const DRAG_THRESHOLD = 4;
34
- export function FlowCanvas({ nodes, edges, editable, designMode, selectedId, selectedEdgeId, locale, activeNodeId, visitedNodeIds, traversedEdgeIds, onSelect, onSelectEdge, onPatch, }) {
35
+ export function FlowCanvas({ nodes, edges, editable, designMode, selectedId, selectedEdgeId, locale, activeNodeId, visitedNodeIds, traversedEdgeIds, invalidNodeIds, invalidEdges, onRevealProblem, problems, revealSignal, onSelect, onSelectEdge, onPatch, }) {
35
36
  const viewportRef = React.useRef(null);
36
37
  const [zoom, setZoom] = React.useState(1);
37
38
  const [pan, setPan] = React.useState({ x: 0, y: 0 });
@@ -51,7 +52,15 @@ export function FlowCanvas({ nodes, edges, editable, designMode, selectedId, sel
51
52
  // Simulation overlay sets (display-only; never drives engine behavior).
52
53
  const visitedSet = React.useMemo(() => new Set(visitedNodeIds ?? []), [visitedNodeIds]);
53
54
  const traversedSet = React.useMemo(() => new Set(traversedEdgeIds ?? []), [traversedEdgeIds]);
55
+ const invalidNodeSet = React.useMemo(() => new Set(invalidNodeIds ?? []), [invalidNodeIds]);
54
56
  const simRunning = (visitedNodeIds?.length ?? 0) > 0 || !!activeNodeId;
57
+ // Per-element validation badges (errors dominate warnings on the same
58
+ // element). Derived from the live `problems` list so badges clear as issues
59
+ // are resolved.
60
+ const { byNode: nodeBadges, byEdge: edgeBadges } = React.useMemo(() => indexProblemBadges(problems ?? []), [problems]);
61
+ // Error-level problems shown in the always-visible inline banner — driven by
62
+ // the same `problems` list as the panel/badges so the three stay in lock-step.
63
+ const bannerErrors = React.useMemo(() => (problems ?? []).filter((p) => p.level === 'error'), [problems]);
55
64
  const positionOf = React.useCallback((id) => {
56
65
  if (dragPos && dragPos.id === id)
57
66
  return { x: dragPos.x, y: dragPos.y };
@@ -88,6 +97,28 @@ export function FlowCanvas({ nodes, edges, editable, designMode, selectedId, sel
88
97
  source: opts.from,
89
98
  target: id,
90
99
  };
100
+ // When the source is a decision, carry its matching branch (by order:
101
+ // the k-th out-edge takes the k-th branch) onto the new edge so it
102
+ // actually routes. The decision's config.conditions are otherwise
103
+ // disconnected from the edges, leaving every branch unconditional.
104
+ const fromNode = nodes.find((n) => n.id === opts.from);
105
+ if (fromNode?.type === 'decision') {
106
+ const branches = Array.isArray(fromNode.config?.conditions)
107
+ ? fromNode.config.conditions
108
+ : [];
109
+ const outCount = edges.filter((e) => e.source === opts.from).length;
110
+ const branch = branches[outCount];
111
+ if (branch && typeof branch === 'object') {
112
+ const expr = typeof branch.expression === 'string' ? branch.expression.trim() : '';
113
+ const label = typeof branch.label === 'string' ? branch.label.trim() : '';
114
+ if (label)
115
+ newEdge.label = label;
116
+ if (expr === 'true')
117
+ newEdge.isDefault = true;
118
+ else if (expr)
119
+ newEdge.condition = expr;
120
+ }
121
+ }
91
122
  patch.edges = appendArray(edges, newEdge);
92
123
  }
93
124
  onPatch(patch);
@@ -124,6 +155,49 @@ export function FlowCanvas({ nodes, edges, editable, designMode, selectedId, sel
124
155
  onPatch({ nodes: appendArray(nodes, newNode), edges: appendArray(nextEdges, secondSegment) });
125
156
  onSelect(newNode);
126
157
  }, [edges, nodes, onPatch, onSelect, positionOf]);
158
+ /**
159
+ * ADR-0044 one-click "add revision loop": drop a signal `wait` node plus the
160
+ * two edges that form a send-back-for-revision loop on an approval node —
161
+ * a `revise` out-edge to the wait point, and a declared `back`-edge closing
162
+ * the loop (resubmit re-enters the approval node as round N+1). Reproduces the
163
+ * canonical `showcase_budget_approval` shape in a single gesture. The wait
164
+ * node is left unpinned so the layered auto-layout slots it among the
165
+ * approval node's other branches.
166
+ */
167
+ const addReviseLoop = React.useCallback((approvalId) => {
168
+ if (!onPatch)
169
+ return;
170
+ if (!nodes.some((n) => n.id === approvalId))
171
+ return;
172
+ const waitId = uniqueId('node', nodes.map((n) => n.id).filter(Boolean));
173
+ const waitNode = {
174
+ id: waitId,
175
+ type: 'wait',
176
+ label: 'Awaiting Revision',
177
+ // Signal-flavored wait: the submitter's resubmit signal resumes the run.
178
+ waitEventConfig: { eventType: 'signal', signalName: 'revision', onTimeout: 'fail' },
179
+ };
180
+ const existingEdgeIds = edges.map((e) => e.id).filter(Boolean);
181
+ const reviseId = uniqueId('edge', existingEdgeIds);
182
+ const backId = uniqueId('edge', [...existingEdgeIds, reviseId]);
183
+ const reviseEdge = { id: reviseId, source: approvalId, target: waitId, label: 'revise' };
184
+ const backEdge = { id: backId, source: waitId, target: approvalId, label: 'resubmit', type: 'back' };
185
+ onPatch({
186
+ nodes: appendArray(nodes, waitNode),
187
+ edges: appendArray(appendArray(edges, reviseEdge), backEdge),
188
+ });
189
+ onSelect(waitNode);
190
+ }, [edges, nodes, onPatch, onSelect]);
191
+ // Approval nodes that already declare a `revise` out-edge — used to hide the
192
+ // "add revision loop" affordance once a loop exists (avoid duplicates).
193
+ const reviseLoopSources = React.useMemo(() => {
194
+ const s = new Set();
195
+ for (const e of edges) {
196
+ if (typeof e.label === 'string' && e.label.trim().toLowerCase() === 'revise')
197
+ s.add(e.source);
198
+ }
199
+ return s;
200
+ }, [edges]);
127
201
  const deleteNode = React.useCallback((id) => {
128
202
  if (!onPatch)
129
203
  return;
@@ -206,6 +280,31 @@ export function FlowCanvas({ nodes, edges, editable, designMode, selectedId, sel
206
280
  y: Math.max(16, (vp.clientHeight - size.height * z) / 2),
207
281
  });
208
282
  }, [size.height, size.width]);
283
+ // Pan to center an element when the Problems panel asks to reveal it. Driven
284
+ // by a changing `nonce` so re-clicking the same problem re-centers it.
285
+ React.useEffect(() => {
286
+ if (!revealSignal)
287
+ return;
288
+ const vp = viewportRef.current;
289
+ if (!vp)
290
+ return;
291
+ const t = revealSignal.target;
292
+ let pt = null;
293
+ if (t.kind === 'node') {
294
+ const p = layout.get(t.nodeId);
295
+ if (p)
296
+ pt = { x: p.x + NODE_W / 2, y: p.y + NODE_H / 2 };
297
+ }
298
+ else if (t.kind === 'edge') {
299
+ const s = layout.get(t.source);
300
+ const d = layout.get(t.target);
301
+ if (s && d)
302
+ pt = { x: (s.x + d.x) / 2 + NODE_W / 2, y: (s.y + d.y) / 2 + NODE_H / 2 };
303
+ }
304
+ if (pt)
305
+ setPan({ x: vp.clientWidth / 2 - pt.x * zoom, y: vp.clientHeight / 2 - pt.y * zoom });
306
+ // eslint-disable-next-line react-hooks/exhaustive-deps
307
+ }, [revealSignal?.nonce]);
209
308
  // ── Keyboard: delete selected node ─────────────────────────────────────────
210
309
  const onKeyDown = React.useCallback((e) => {
211
310
  if (!editable || !selectedId)
@@ -220,7 +319,10 @@ export function FlowCanvas({ nodes, edges, editable, designMode, selectedId, sel
220
319
  }
221
320
  }, [deleteNode, editable, selectedId]);
222
321
  // ── Render ─────────────────────────────────────────────────────────────────
223
- return (_jsxs("div", { className: "relative h-full min-h-[320px] w-full overflow-hidden", children: [_jsxs("div", { className: "absolute right-2 top-2 z-30 flex items-center gap-1.5", children: [editable && (_jsxs("div", { className: "relative", children: [_jsxs("button", { type: "button", onClick: () => setPaletteOpen((v) => !v), className: "inline-flex items-center gap-1.5 rounded-lg border bg-background/90 px-2.5 py-1.5 text-xs font-medium shadow-sm backdrop-blur-sm transition-colors hover:border-primary/50 hover:bg-accent hover:text-foreground", children: [_jsx(Plus, { className: "h-3.5 w-3.5" }), tr('engine.inspector.add.node', locale)] }), paletteOpen && (_jsx(NodePalette, { locale: locale, items: paletteItems, onClose: () => setPaletteOpen(false), onPick: (type) => addNode(type, { from: selectedId ?? undefined }) }))] })), _jsxs("div", { className: "flex items-center rounded-lg border bg-background/90 shadow-sm backdrop-blur-sm", children: [_jsx("button", { type: "button", title: "Zoom out", "aria-label": "Zoom out", onClick: () => setZoom((z) => clampZoom(z - 0.15)), className: "inline-flex h-7 w-7 items-center justify-center text-muted-foreground hover:text-foreground", children: _jsx(ZoomOut, { className: "h-3.5 w-3.5" }) }), _jsxs("span", { className: "w-10 text-center text-[11px] tabular-nums text-muted-foreground", children: [Math.round(zoom * 100), "%"] }), _jsx("button", { type: "button", title: "Zoom in", "aria-label": "Zoom in", onClick: () => setZoom((z) => clampZoom(z + 0.15)), className: "inline-flex h-7 w-7 items-center justify-center text-muted-foreground hover:text-foreground", children: _jsx(ZoomIn, { className: "h-3.5 w-3.5" }) }), _jsx("button", { type: "button", title: "Fit to view", "aria-label": "Fit to view", onClick: fitToView, className: "inline-flex h-7 w-7 items-center justify-center border-l text-muted-foreground hover:text-foreground", children: _jsx(Maximize2, { className: "h-3.5 w-3.5" }) })] })] }), _jsx("div", { ref: viewportRef, tabIndex: 0, role: "application", "aria-label": "Flow canvas", onKeyDown: onKeyDown, onPointerDown: onBgPointerDown, onPointerMove: (e) => {
322
+ return (_jsxs("div", { className: "relative h-full min-h-[320px] w-full overflow-hidden", children: [bannerErrors.length > 0 && (_jsxs("div", { className: "absolute left-2 top-2 z-30 max-w-[min(60%,420px)] space-y-1", children: [bannerErrors.slice(0, 3).map((p) => {
323
+ const clickable = !!onRevealProblem && p.target.kind !== 'flow';
324
+ return (_jsxs("button", { type: "button", role: "alert", disabled: !clickable, onPointerDown: (e) => e.stopPropagation(), onClick: clickable ? (e) => { e.stopPropagation(); onRevealProblem(p); } : undefined, title: clickable ? 'Reveal on canvas' : undefined, className: cn('flex w-full items-start gap-1.5 rounded-lg border border-destructive/40 bg-destructive/10 px-2.5 py-1.5 text-left text-[11px] leading-snug text-destructive shadow-sm backdrop-blur-sm transition-colors', clickable && 'cursor-pointer hover:border-destructive/60 hover:bg-destructive/20'), children: [_jsx(AlertTriangle, { className: "mt-0.5 h-3.5 w-3.5 shrink-0" }), _jsx("span", { children: p.message })] }, p.id));
325
+ }), bannerErrors.length > 3 && (_jsxs("div", { className: "px-2.5 text-[10px] text-destructive/80", children: ["+", bannerErrors.length - 3, " more\u2026"] }))] })), _jsxs("div", { className: "absolute right-2 top-2 z-30 flex items-center gap-1.5", children: [editable && (_jsxs("div", { className: "relative", children: [_jsxs("button", { type: "button", onClick: () => setPaletteOpen((v) => !v), className: "inline-flex items-center gap-1.5 rounded-lg border bg-background/90 px-2.5 py-1.5 text-xs font-medium shadow-sm backdrop-blur-sm transition-colors hover:border-primary/50 hover:bg-accent hover:text-foreground", children: [_jsx(Plus, { className: "h-3.5 w-3.5" }), tr('engine.inspector.add.node', locale)] }), paletteOpen && (_jsx(NodePalette, { locale: locale, items: paletteItems, onClose: () => setPaletteOpen(false), onPick: (type) => addNode(type, { from: selectedId ?? undefined }) }))] })), _jsxs("div", { className: "flex items-center rounded-lg border bg-background/90 shadow-sm backdrop-blur-sm", children: [_jsx("button", { type: "button", title: "Zoom out", "aria-label": "Zoom out", onClick: () => setZoom((z) => clampZoom(z - 0.15)), className: "inline-flex h-7 w-7 items-center justify-center text-muted-foreground hover:text-foreground", children: _jsx(ZoomOut, { className: "h-3.5 w-3.5" }) }), _jsxs("span", { className: "w-10 text-center text-[11px] tabular-nums text-muted-foreground", children: [Math.round(zoom * 100), "%"] }), _jsx("button", { type: "button", title: "Zoom in", "aria-label": "Zoom in", onClick: () => setZoom((z) => clampZoom(z + 0.15)), className: "inline-flex h-7 w-7 items-center justify-center text-muted-foreground hover:text-foreground", children: _jsx(ZoomIn, { className: "h-3.5 w-3.5" }) }), _jsx("button", { type: "button", title: "Fit to view", "aria-label": "Fit to view", onClick: fitToView, className: "inline-flex h-7 w-7 items-center justify-center border-l text-muted-foreground hover:text-foreground", children: _jsx(Maximize2, { className: "h-3.5 w-3.5" }) })] })] }), _jsx("div", { ref: viewportRef, tabIndex: 0, role: "application", "aria-label": "Flow canvas", onKeyDown: onKeyDown, onPointerDown: onBgPointerDown, onPointerMove: (e) => {
224
326
  onBgPointerMove(e);
225
327
  onNodePointerMove(e);
226
328
  }, onPointerUp: (e) => {
@@ -238,44 +340,69 @@ export function FlowCanvas({ nodes, edges, editable, designMode, selectedId, sel
238
340
  width: size.width,
239
341
  height: size.height,
240
342
  transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
241
- }, children: [_jsxs("svg", { className: "pointer-events-none absolute left-0 top-0 overflow-visible", width: size.width, height: size.height, children: [_jsx("defs", { children: _jsx("marker", { id: "flow-arrow", viewBox: "0 0 10 10", refX: "8", refY: "5", markerWidth: "7", markerHeight: "7", orient: "auto-start-reverse", children: _jsx("path", { d: "M 0 0 L 10 5 L 0 10 z", className: "fill-muted-foreground/55" }) }) }), edges.map((edge, i) => {
343
+ }, children: [_jsxs("svg", { className: "pointer-events-none absolute left-0 top-0 overflow-visible", width: size.width, height: size.height, children: [_jsxs("defs", { children: [_jsx("marker", { id: "flow-arrow", viewBox: "0 0 10 10", refX: "8", refY: "5", markerWidth: "7", markerHeight: "7", orient: "auto-start-reverse", children: _jsx("path", { d: "M 0 0 L 10 5 L 0 10 z", className: "fill-muted-foreground/55" }) }), _jsx("marker", { id: "flow-arrow-back", viewBox: "0 0 10 10", refX: "8", refY: "5", markerWidth: "7", markerHeight: "7", orient: "auto-start-reverse", children: _jsx("path", { d: "M 0 0 L 10 5 L 0 10 z", className: "fill-amber-500/80" }) }), _jsx("marker", { id: "flow-arrow-error", viewBox: "0 0 10 10", refX: "8", refY: "5", markerWidth: "7", markerHeight: "7", orient: "auto-start-reverse", children: _jsx("path", { d: "M 0 0 L 10 5 L 0 10 z", className: "fill-destructive" }) })] }), edges.map((edge, i) => {
242
344
  const sp = layout.get(edge.source);
243
345
  const tp = layout.get(edge.target);
244
346
  if (!sp || !tp)
245
347
  return null;
246
- const from = bottomAnchor(dragPos?.id === edge.source ? positionOf(edge.source) : sp);
247
- const to = topAnchor(dragPos?.id === edge.target ? positionOf(edge.target) : tp);
248
- const mid = edgeMidpoint(from, to);
348
+ // ADR-0044 back-edges (revise loop) re-enter an earlier node, so
349
+ // they attach to the right side of both endpoints and render as a
350
+ // dashed amber return arc — visually distinct from the forward
351
+ // top-to-bottom flow.
352
+ const back = isBackEdge(edge);
353
+ // Structural-validation error (e.g. part of an un-declared cycle).
354
+ // Back-edges are excluded from cycle detection, so they're never invalid.
355
+ const invalid = !back && !!invalidEdges?.has(`${edge.source}->${edge.target}`);
356
+ const sPos = dragPos?.id === edge.source ? positionOf(edge.source) : sp;
357
+ const tPos = dragPos?.id === edge.target ? positionOf(edge.target) : tp;
358
+ const from = back ? rightAnchor(sPos) : bottomAnchor(sPos);
359
+ const to = back ? rightAnchor(tPos) : topAnchor(tPos);
360
+ const labelPos = back ? backEdgeLabelAnchor(from, to) : edgeMidpoint(from, to);
249
361
  const cond = conditionText(edge.condition);
250
362
  const branchLabel = edge.isDefault ? 'else' : cond ? `if ${cond}` : edge.label;
251
363
  const eid = edgeKey(edge, i);
364
+ const edgeBadge = edgeBadges.get(edgeProblemKey(edge.source, edge.target));
252
365
  const traversed = traversedSet.has(eid);
253
366
  const selected = selectedEdgeId === eid;
254
- const d = edgePath(from, to);
367
+ const d = back ? backEdgePath(from, to) : edgePath(from, to);
255
368
  // Edges are selectable in design mode; the host opens the edge
256
369
  // inspector. A wide transparent hit-path widens the click target
257
370
  // beyond the 1.5px visible stroke without altering the visuals.
258
371
  const selectable = designMode && !!onSelectEdge;
259
- return (_jsxs("g", { children: [_jsx("path", { d: d, strokeLinecap: "round", className: cn('fill-none transition-[stroke] duration-150', traversed
372
+ return (_jsxs("g", { "data-invalid": invalid || undefined, children: [_jsx("path", { d: d, strokeLinecap: "round", strokeDasharray: back ? '5 4' : undefined, className: cn('fill-none transition-[stroke] duration-150', traversed
260
373
  ? 'stroke-sky-500'
261
374
  : selected
262
375
  ? 'stroke-primary'
263
- : simRunning
264
- ? 'stroke-muted-foreground/20'
265
- : 'stroke-muted-foreground/40'), strokeWidth: traversed || selected ? 2.5 : 1.75, markerEnd: "url(#flow-arrow)" }), selectable && (_jsx("path", { d: d, className: "pointer-events-auto cursor-pointer fill-none stroke-transparent", strokeWidth: 14, onPointerDown: (e) => e.stopPropagation(), onClick: (e) => {
376
+ : invalid
377
+ ? 'stroke-destructive'
378
+ : back
379
+ ? 'stroke-amber-500/70'
380
+ : simRunning
381
+ ? 'stroke-muted-foreground/20'
382
+ : 'stroke-muted-foreground/40'), strokeWidth: traversed || selected || invalid ? 2.5 : 1.75, markerEnd: invalid ? 'url(#flow-arrow-error)' : back ? 'url(#flow-arrow-back)' : 'url(#flow-arrow)' }), selectable && (_jsx("path", { d: d, className: "pointer-events-auto cursor-pointer fill-none stroke-transparent", strokeWidth: 14, onPointerDown: (e) => e.stopPropagation(), onClick: (e) => {
266
383
  e.stopPropagation();
267
384
  onSelectEdge(edge, eid);
268
- }, children: _jsx("title", { children: `${edge.source} → ${edge.target}` }) })), branchLabel && (_jsx("foreignObject", { x: mid.x - 60, y: mid.y - 11, width: 120, height: 22, className: cn(selectable && 'pointer-events-auto'), children: _jsx("div", { className: "flex justify-center", children: _jsx("span", { onPointerDown: selectable ? (e) => e.stopPropagation() : undefined, onClick: selectable ? (e) => { e.stopPropagation(); onSelectEdge(edge, eid); } : undefined, className: cn('max-w-full truncate rounded-full border bg-background/95 px-2 py-0.5 text-[10px] font-medium shadow-sm backdrop-blur-sm transition-colors', selectable && 'cursor-pointer hover:border-primary/60', selected ? 'border-primary text-primary' : 'border-border text-muted-foreground'), children: branchLabel }) }) })), editable && (_jsx("foreignObject", {
385
+ }, children: _jsx("title", { children: invalid ? `${edge.source} → ${edge.target} — part of an un-declared cycle; mark the edge that closes the loop as a back-edge` : back ? `${edge.source} ↩ ${edge.target} (back-edge)` : `${edge.source} → ${edge.target}` }) })), branchLabel && (_jsx("foreignObject", { x: labelPos.x - 60, y: labelPos.y - 11, width: 120, height: 22, className: cn(selectable && 'pointer-events-auto'), children: _jsx("div", { className: "flex justify-center", children: _jsx("span", { onPointerDown: selectable ? (e) => e.stopPropagation() : undefined, onClick: selectable ? (e) => { e.stopPropagation(); onSelectEdge(edge, eid); } : undefined, className: cn('max-w-full truncate rounded-full border bg-background/95 px-2 py-0.5 text-[10px] font-medium shadow-sm backdrop-blur-sm transition-colors', selectable && 'cursor-pointer hover:border-primary/60', selected
386
+ ? 'border-primary text-primary'
387
+ : invalid
388
+ ? 'border-destructive/60 text-destructive'
389
+ : back
390
+ ? 'border-amber-500/50 text-amber-600 dark:text-amber-400'
391
+ : 'border-border text-muted-foreground'), children: branchLabel }) }) })), edgeBadge && (_jsx("foreignObject", { x: labelPos.x - 9, y: labelPos.y - 30, width: 18, height: 18, className: "pointer-events-auto overflow-visible", children: _jsx("span", { title: edgeBadge.title, "data-problem": edgeBadge.level, className: cn('inline-flex h-[18px] w-[18px] items-center justify-center rounded-full border bg-background shadow-sm', edgeBadge.level === 'error'
392
+ ? 'border-destructive/50 text-destructive'
393
+ : 'border-amber-500/50 text-amber-600 dark:text-amber-400'), children: edgeBadge.level === 'error' ? (_jsx(AlertCircle, { className: "h-3 w-3" })) : (_jsx(AlertTriangle, { className: "h-3 w-3" })) }) })), editable && !back && (_jsx("foreignObject", {
269
394
  // Sit the insert handle at the edge midpoint, but slide it
270
395
  // to the right of the branch-label pill when one is present
271
396
  // so the two don't stack on the same spot.
272
- x: branchLabel ? mid.x + 66 : mid.x - 11, y: mid.y - 11, width: 22, height: 22, className: "pointer-events-auto", children: _jsx("button", { type: "button", title: "Insert node here", "aria-label": "Insert node here", onPointerDown: (e) => e.stopPropagation(), onClick: (e) => {
397
+ x: branchLabel ? labelPos.x + 66 : labelPos.x - 11, y: labelPos.y - 11, width: 22, height: 22, className: "pointer-events-auto", children: _jsx("button", { type: "button", title: "Insert node here", "aria-label": "Insert node here", onPointerDown: (e) => e.stopPropagation(), onClick: (e) => {
273
398
  e.stopPropagation();
274
399
  insertOnEdge(edge);
275
400
  }, className: "inline-flex h-[22px] w-[22px] items-center justify-center rounded-full border bg-background/90 text-muted-foreground opacity-50 shadow-sm backdrop-blur-sm transition-all hover:scale-110 hover:border-primary hover:bg-background hover:text-primary hover:opacity-100 focus-visible:opacity-100", children: _jsx(Plus, { className: "h-3 w-3" }) }) }))] }, edge.id || `${edge.source}-${edge.target}-${i}`));
276
401
  })] }), nodes.map((node) => {
277
402
  const runState = activeNodeId === node.id ? 'active' : visitedSet.has(node.id) ? 'visited' : undefined;
278
- return (_jsx(NodeCard, { id: node.id, type: node.type, label: node.label || node.id, summary: nodeSummary(node), position: positionOf(node.id), selected: selectedId === node.id, editable: editable, runState: runState, dimmed: simRunning && !runState, onPointerDown: onNodePointerDown(node.id), onSelect: () => designMode && onSelect(node), onAppend: () => addNode('create_record', { from: node.id }) }, node.id));
403
+ return (_jsx(NodeCard, { id: node.id, type: node.type, label: node.label || node.id, summary: nodeSummary(node), position: positionOf(node.id), selected: selectedId === node.id, editable: editable, runState: runState, dimmed: simRunning && !runState, onPointerDown: onNodePointerDown(node.id), onSelect: () => designMode && onSelect(node), onAppend: () => addNode('create_record', { from: node.id }), onAddReviseLoop: editable && node.type === 'approval' && !reviseLoopSources.has(node.id)
404
+ ? () => addReviseLoop(node.id)
405
+ : undefined, invalid: invalidNodeSet.has(node.id), badge: nodeBadges.get(node.id) }, node.id));
279
406
  })] }) })] }));
280
407
  }
281
408
  /** One-line config summary shown on the node card (best-effort, type-aware). */
@@ -17,4 +17,4 @@
17
17
  */
18
18
  import * as React from 'react';
19
19
  import type { MetadataPreviewProps } from '../preview-registry';
20
- export declare function FlowPreview({ draft, editing, selection, onSelectionChange, onPatch, locale }: MetadataPreviewProps): React.JSX.Element;
20
+ export declare function FlowPreview({ draft, editing, selection, onSelectionChange, onPatch, locale, diagnostics }: MetadataPreviewProps): React.JSX.Element;
@@ -18,17 +18,21 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
18
18
  * edges or duplicate node ids; we never throw, we just degrade.
19
19
  */
20
20
  import * as React from 'react';
21
- import { Bug, CircleDot, GitBranch, History, PanelRight, Plus, Settings2, Variable, Zap, } from 'lucide-react';
21
+ import { AlertCircle, Bug, CircleDot, GitBranch, History, PanelRight, Plus, Settings2, Variable, Zap, } from 'lucide-react';
22
22
  import { PreviewShell, PreviewMessage, PreviewErrorBoundary } from './PreviewShell';
23
23
  import { uniqueId, appendArray } from '../inspectors/_shared';
24
24
  import { t as tr } from '../i18n';
25
25
  import { FlowCanvas } from './FlowCanvas';
26
26
  import { FlowSimulatorPanel } from './FlowSimulatorPanel';
27
27
  import { FlowRunsPanel } from './FlowRunsPanel';
28
- export function FlowPreview({ draft, editing, selection, onSelectionChange, onPatch, locale }) {
28
+ import { ProblemsPanel } from './ProblemsPanel';
29
+ import { buildFlowProblems, deriveInvalidElements } from './flow-problems';
30
+ export function FlowPreview({ draft, editing, selection, onSelectionChange, onPatch, locale, diagnostics }) {
29
31
  const d = draft;
30
- const nodes = Array.isArray(d.nodes) ? d.nodes : [];
31
- const edges = Array.isArray(d.edges) ? d.edges : [];
32
+ // Memoized so hook deps (validation memo, handleAddNode) get a stable array
33
+ // reference across renders instead of a fresh `[]`/cast each time.
34
+ const nodes = React.useMemo(() => (Array.isArray(d.nodes) ? d.nodes : []), [d.nodes]);
35
+ const edges = React.useMemo(() => (Array.isArray(d.edges) ? d.edges : []), [d.edges]);
32
36
  const variables = Array.isArray(d.variables) ? d.variables : [];
33
37
  const designMode = !!(editing && onSelectionChange);
34
38
  const canEdit = designMode && !!onPatch;
@@ -37,7 +41,33 @@ export function FlowPreview({ draft, editing, selection, onSelectionChange, onPa
37
41
  const [showDebug, setShowDebug] = React.useState(false);
38
42
  const [showVars, setShowVars] = React.useState(true);
39
43
  const [showRuns, setShowRuns] = React.useState(false);
44
+ const [showProblems, setShowProblems] = React.useState(false);
40
45
  const [runHL, setRunHL] = React.useState(null);
46
+ // Unified problem list (structural + server `_diagnostics`) is the SINGLE
47
+ // source for every validation surface — the clickable inline banner, the
48
+ // per-element badges, the red error ring/stroke, and the Problems panel.
49
+ // Recomputed from the live draft so they all clear as the author fixes each issue.
50
+ const problems = React.useMemo(() => buildFlowProblems({ nodes, edges, serverDiagnostics: diagnostics, variables }), [nodes, edges, diagnostics, d.variables]);
51
+ const errorCount = problems.filter((p) => p.level === 'error').length;
52
+ // Red error ring/stroke derived from the same list (errors only; a cycle
53
+ // paints its whole loop) — no second validateFlowDraft pass.
54
+ const { invalidNodeIds, invalidEdges } = React.useMemo(() => deriveInvalidElements(problems), [problems]);
55
+ // "Reveal" handshake with the canvas: a changing nonce pans to the element.
56
+ const [reveal, setReveal] = React.useState(null);
57
+ const selectedKey = selectedId ? `node:${selectedId}` : (selectedEdgeId ?? null);
58
+ const handleSelectProblem = React.useCallback((p) => {
59
+ if (p.target.kind === 'node') {
60
+ // Destructure before the .find() closure — TS drops the union narrowing
61
+ // of `p.target` inside a nested callback, so capture nodeId as a string.
62
+ const { nodeId } = p.target;
63
+ const node = nodes.find((n) => n.id === nodeId);
64
+ onSelectionChange?.({ kind: 'node', id: nodeId, label: node?.label || nodeId });
65
+ }
66
+ else if (p.target.kind === 'edge') {
67
+ onSelectionChange?.({ kind: 'edge', id: p.target.edgeKey, label: `${p.target.source} → ${p.target.target}` });
68
+ }
69
+ setReveal((r) => ({ target: p.target, nonce: (r?.nonce ?? 0) + 1 }));
70
+ }, [nodes, onSelectionChange]);
41
71
  const handleAddNode = React.useCallback(() => {
42
72
  if (!canEdit)
43
73
  return;
@@ -61,26 +91,36 @@ export function FlowPreview({ draft, editing, selection, onSelectionChange, onPa
61
91
  return (_jsx(PreviewShell, { hint: `flow${designMode ? ' · design' : ''}`, children: canEdit ? (_jsx("div", { className: "p-3", children: _jsxs("button", { type: "button", className: "inline-flex items-center gap-1 rounded border border-dashed px-3 py-2 text-xs text-muted-foreground hover:bg-muted/30 hover:text-foreground", onClick: handleAddNode, children: [_jsx(Plus, { className: "h-3 w-3" }), tr('engine.inspector.add.node', locale)] }) })) : (_jsx(PreviewMessage, { children: "Add nodes in the Form tab to see the flow preview." })) }));
62
92
  }
63
93
  return (_jsx(PreviewShell, { hint: `flow · ${nodes.length} node${nodes.length === 1 ? '' : 's'}`, children: _jsx(PreviewErrorBoundary, { fallbackHint: "One of the flow nodes or edges is malformed.", children: _jsxs("div", { className: 'grid gap-0 h-full min-h-[440px] ' +
64
- (showDebug || showVars || showRuns ? 'lg:grid-cols-[1fr_240px]' : 'grid-cols-1'), children: [_jsxs("div", { className: "flex flex-col min-w-0 min-h-0", children: [_jsxs("div", { className: "rounded-none border-b bg-muted/30 px-3 py-2 text-xs flex flex-wrap items-center gap-x-4 gap-y-1", children: [_jsx(Pill, { icon: Zap, label: "Trigger", value: flowType }), _jsx(Pill, { icon: CircleDot, label: "Status", value: status, tone: status === 'active' ? 'green' : status === 'draft' ? 'gray' : 'amber' }), _jsx(Pill, { icon: Settings2, label: "Run as", value: runAs }), version && _jsx(Pill, { label: "v", value: version }), errorStrategy && _jsx(Pill, { icon: GitBranch, label: "On error", value: errorStrategy }), _jsxs("div", { className: "ml-auto flex items-center gap-1.5", children: [!showDebug && !showRuns && (_jsxs("button", { type: "button", onClick: () => setShowVars((v) => !v), className: 'inline-flex items-center gap-1 rounded border px-2 py-0.5 text-[11px] font-medium transition-colors ' +
94
+ (showDebug || showVars || showRuns || showProblems ? 'lg:grid-cols-[1fr_240px]' : 'grid-cols-1'), children: [_jsxs("div", { className: "flex flex-col min-w-0 min-h-0", children: [_jsxs("div", { className: "rounded-none border-b bg-muted/30 px-3 py-2 text-xs flex flex-wrap items-center gap-x-4 gap-y-1", children: [_jsx(Pill, { icon: Zap, label: "Trigger", value: flowType }), _jsx(Pill, { icon: CircleDot, label: "Status", value: status, tone: status === 'active' ? 'green' : status === 'draft' ? 'gray' : 'amber' }), _jsx(Pill, { icon: Settings2, label: "Run as", value: runAs }), version && _jsx(Pill, { label: "v", value: version }), errorStrategy && _jsx(Pill, { icon: GitBranch, label: "On error", value: errorStrategy }), _jsxs("div", { className: "ml-auto flex items-center gap-1.5", children: [!showDebug && !showRuns && !showProblems && (_jsxs("button", { type: "button", onClick: () => setShowVars((v) => !v), className: 'inline-flex items-center gap-1 rounded border px-2 py-0.5 text-[11px] font-medium transition-colors ' +
65
95
  (showVars
66
96
  ? 'border-violet-500 bg-violet-50 text-violet-700'
67
97
  : 'border-border text-muted-foreground hover:bg-muted/50 hover:text-foreground'), title: showVars ? 'Hide variables panel' : 'Show variables panel', children: [_jsx(PanelRight, { className: "h-3 w-3" }), " Variables"] })), flowName && (_jsxs("button", { type: "button", onClick: () => {
68
98
  setShowRuns((v) => !v);
69
99
  setShowDebug(false);
100
+ setShowProblems(false);
70
101
  }, className: 'inline-flex items-center gap-1 rounded border px-2 py-0.5 text-[11px] font-medium transition-colors ' +
71
102
  (showRuns
72
103
  ? 'border-emerald-500 bg-emerald-50 text-emerald-700'
73
104
  : 'border-border text-muted-foreground hover:bg-muted/50 hover:text-foreground'), title: "Run history from the automation engine", children: [_jsx(History, { className: "h-3 w-3" }), " Runs"] })), _jsxs("button", { type: "button", onClick: () => {
105
+ setShowProblems((v) => !v);
106
+ setShowDebug(false);
107
+ setShowRuns(false);
108
+ }, className: 'inline-flex items-center gap-1 rounded border px-2 py-0.5 text-[11px] font-medium transition-colors ' +
109
+ (showProblems
110
+ ? 'border-rose-500 bg-rose-50 text-rose-700'
111
+ : 'border-border text-muted-foreground hover:bg-muted/50 hover:text-foreground'), title: "Validation problems", children: [_jsx(AlertCircle, { className: "h-3 w-3" }), " Problems", problems.length > 0 && (_jsx("span", { className: 'ml-0.5 inline-flex min-w-[16px] items-center justify-center rounded-full px-1 text-[10px] font-semibold ' +
112
+ (errorCount > 0 ? 'bg-destructive/15 text-destructive' : 'bg-amber-500/15 text-amber-600'), children: problems.length }))] }), _jsxs("button", { type: "button", onClick: () => {
74
113
  setShowDebug((v) => !v);
75
114
  setShowRuns(false);
115
+ setShowProblems(false);
76
116
  }, className: 'inline-flex items-center gap-1 rounded border px-2 py-0.5 text-[11px] font-medium transition-colors ' +
77
117
  (showDebug
78
118
  ? 'border-sky-500 bg-sky-50 text-sky-700'
79
- : 'border-border text-muted-foreground hover:bg-muted/50 hover:text-foreground'), children: [_jsx(Bug, { className: "h-3 w-3" }), " Debug"] })] })] }), _jsx("div", { className: "flex-1 min-h-0", children: _jsx(FlowCanvas, { nodes: nodes, edges: edges, editable: canEdit, designMode: designMode, selectedId: selectedId, selectedEdgeId: selectedEdgeId, locale: locale, activeNodeId: runHL?.activeNodeId ?? null, visitedNodeIds: runHL?.visitedNodeIds, traversedEdgeIds: runHL?.traversedEdgeIds, onSelect: (n) => n
119
+ : 'border-border text-muted-foreground hover:bg-muted/50 hover:text-foreground'), children: [_jsx(Bug, { className: "h-3 w-3" }), " Debug"] })] })] }), _jsx("div", { className: "flex-1 min-h-0", children: _jsx(FlowCanvas, { nodes: nodes, edges: edges, editable: canEdit, designMode: designMode, selectedId: selectedId, selectedEdgeId: selectedEdgeId, locale: locale, activeNodeId: runHL?.activeNodeId ?? null, visitedNodeIds: runHL?.visitedNodeIds, traversedEdgeIds: runHL?.traversedEdgeIds, invalidNodeIds: invalidNodeIds, invalidEdges: invalidEdges, onRevealProblem: handleSelectProblem, problems: problems, revealSignal: reveal, onSelect: (n) => n
80
120
  ? onSelectionChange?.({ kind: 'node', id: n.id, label: n.label || n.id })
81
121
  : onSelectionChange?.(null), onSelectEdge: (e, key) => e
82
122
  ? onSelectionChange?.({ kind: 'edge', id: key, label: `${e.source} → ${e.target}` })
83
- : onSelectionChange?.(null), onPatch: onPatch }) })] }), showDebug ? (_jsx("div", { className: "border-l bg-muted/20", children: _jsx(FlowSimulatorPanel, { nodes: nodes, edges: edges, variables: variables, onRunStateChange: setRunHL }) })) : showRuns && flowName ? (_jsx("div", { className: "border-l bg-muted/20", children: _jsx(FlowRunsPanel, { flowName: flowName }) })) : showVars ? (_jsxs("div", { className: "border-l bg-muted/20 p-3 text-xs space-y-2", children: [_jsxs("div", { className: "flex items-center gap-1.5 font-medium text-muted-foreground", children: [_jsx(Variable, { className: "h-3 w-3" }), " Variables"] }), variables.length === 0 ? (_jsx("div", { className: "text-muted-foreground italic", children: "No variables declared." })) : (_jsx("ul", { className: "space-y-1.5", children: variables.map((v, i) => (_jsxs("li", { className: "rounded border bg-background p-1.5", children: [_jsxs("div", { className: "flex items-baseline gap-1 flex-wrap", children: [_jsx("span", { className: "font-mono", children: v.name }), v.type && (_jsx("span", { className: "text-[10px] uppercase text-muted-foreground", children: v.type })), v.isInput && (_jsx("span", { className: "text-[9px] font-semibold uppercase px-1 rounded bg-sky-100 text-sky-700", children: "in" })), v.isOutput && (_jsx("span", { className: "text-[9px] font-semibold uppercase px-1 rounded bg-emerald-100 text-emerald-700", children: "out" }))] }), v.defaultValue !== undefined && (_jsxs("div", { className: "text-[10px] text-muted-foreground font-mono truncate", children: ["= ", String(v.defaultValue)] }))] }, v.name || i))) }))] })) : null] }) }) }));
123
+ : onSelectionChange?.(null), onPatch: onPatch }) })] }), showProblems ? (_jsx("div", { className: "border-l bg-muted/20", children: _jsx(ProblemsPanel, { problems: problems, selectedKey: selectedKey, onSelectProblem: handleSelectProblem }) })) : showDebug ? (_jsx("div", { className: "border-l bg-muted/20", children: _jsx(FlowSimulatorPanel, { nodes: nodes, edges: edges, variables: variables, onRunStateChange: setRunHL }) })) : showRuns && flowName ? (_jsx("div", { className: "border-l bg-muted/20", children: _jsx(FlowRunsPanel, { flowName: flowName }) })) : showVars ? (_jsxs("div", { className: "border-l bg-muted/20 p-3 text-xs space-y-2", children: [_jsxs("div", { className: "flex items-center gap-1.5 font-medium text-muted-foreground", children: [_jsx(Variable, { className: "h-3 w-3" }), " Variables"] }), variables.length === 0 ? (_jsx("div", { className: "text-muted-foreground italic", children: "No variables declared." })) : (_jsx("ul", { className: "space-y-1.5", children: variables.map((v, i) => (_jsxs("li", { className: "rounded border bg-background p-1.5", children: [_jsxs("div", { className: "flex items-baseline gap-1 flex-wrap", children: [_jsx("span", { className: "font-mono", children: v.name }), v.type && (_jsx("span", { className: "text-[10px] uppercase text-muted-foreground", children: v.type })), v.isInput && (_jsx("span", { className: "text-[9px] font-semibold uppercase px-1 rounded bg-sky-100 text-sky-700", children: "in" })), v.isOutput && (_jsx("span", { className: "text-[9px] font-semibold uppercase px-1 rounded bg-emerald-100 text-emerald-700", children: "out" }))] }), v.defaultValue !== undefined && (_jsxs("div", { className: "text-[10px] text-muted-foreground font-mono truncate", children: ["= ", String(v.defaultValue)] }))] }, v.name || i))) }))] })) : null] }) }) }));
84
124
  }
85
125
  function Pill({ icon: Icon, label, value, tone = 'gray', }) {
86
126
  const cls = tone === 'green'
@@ -10,6 +10,7 @@ import * as React from 'react';
10
10
  import { Play, StepForward, RotateCcw, ChevronRight, AlertTriangle, CircleAlert, Plus, Trash2 } from 'lucide-react';
11
11
  import { Button, Input, Label, cn } from '@object-ui/components';
12
12
  import { FlowSimulator } from './simulator/flow-simulator';
13
+ import { ScreenPreview } from './ScreenPreview';
13
14
  /** Coerce a free-text seed value: number / boolean / JSON object|array / string. */
14
15
  function parseSeed(raw) {
15
16
  const s = raw.trim();
@@ -138,6 +139,12 @@ export function FlowSimulatorPanel({ nodes, edges, variables, onRunStateChange }
138
139
  // pressing Run again always reflects the new values.
139
140
  let sim = simRef.current;
140
141
  if (sim && sim.state.status === 'paused') {
142
+ // An approval pause needs an explicit decision (use the branch buttons);
143
+ // blind-resuming would fan out to every branch — so leave it for the user.
144
+ if (sim.state.pausedReason === 'approval') {
145
+ sync();
146
+ return;
147
+ }
141
148
  sim.resume();
142
149
  }
143
150
  else {
@@ -152,9 +159,11 @@ export function FlowSimulatorPanel({ nodes, edges, variables, onRunStateChange }
152
159
  sim.step();
153
160
  sync();
154
161
  };
155
- const onResume = () => {
162
+ const onResume = () => onDecision();
163
+ /** Continue a paused run; `decision` routes an approval down one out-edge. */
164
+ const onDecision = (decision) => {
156
165
  const sim = ensure();
157
- sim.resume();
166
+ sim.resume(decision ? { decision } : {});
158
167
  sim.runToEnd();
159
168
  sync();
160
169
  };
@@ -166,5 +175,30 @@ export function FlowSimulatorPanel({ nodes, edges, variables, onRunStateChange }
166
175
  };
167
176
  const status = snapshot?.status ?? 'idle';
168
177
  const blocked = (validation?.errors.length ?? 0) > 0;
169
- return (_jsxs("div", { className: "flex h-full flex-col text-xs", children: [_jsxs("div", { className: "flex items-center gap-1.5 border-b bg-muted/30 px-3 py-2", children: [_jsxs(Button, { size: "sm", className: "h-7 gap-1 px-2", onClick: onRun, disabled: blocked, children: [_jsx(Play, { className: "h-3.5 w-3.5" }), " Run"] }), _jsxs(Button, { size: "sm", variant: "outline", className: "h-7 gap-1 px-2", onClick: onStep, disabled: blocked || status === 'done' || status === 'error', children: [_jsx(StepForward, { className: "h-3.5 w-3.5" }), " Step"] }), status === 'paused' && (_jsxs(Button, { size: "sm", variant: "outline", className: "h-7 gap-1 px-2", onClick: onResume, children: [_jsx(ChevronRight, { className: "h-3.5 w-3.5" }), " Continue"] })), _jsxs(Button, { size: "sm", variant: "ghost", className: "h-7 gap-1 px-2 text-muted-foreground", onClick: onReset, children: [_jsx(RotateCcw, { className: "h-3.5 w-3.5" }), " Reset"] }), _jsx("span", { className: cn('ml-auto rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase', STATUS_TONE[status === 'idle' || status === 'running' ? 'ok' : status] ?? 'bg-muted'), children: status })] }), _jsxs("div", { className: "min-h-0 flex-1 space-y-3 overflow-auto p-3", children: [validation && (validation.errors.length > 0 || validation.warnings.length > 0) && (_jsxs("div", { className: "space-y-1", children: [validation.errors.map((d, i) => (_jsxs("div", { className: "flex items-start gap-1.5 rounded border border-rose-200 bg-rose-50 px-2 py-1 text-rose-700", children: [_jsx(CircleAlert, { className: "mt-0.5 h-3 w-3 shrink-0" }), _jsx("span", { children: d.message })] }, `e${i}`))), validation.warnings.map((d, i) => (_jsxs("div", { className: "flex items-start gap-1.5 rounded border border-amber-200 bg-amber-50 px-2 py-1 text-amber-700", children: [_jsx(AlertTriangle, { className: "mt-0.5 h-3 w-3 shrink-0" }), _jsx("span", { children: d.message })] }, `w${i}`)))] })), inputs.length > 0 && (_jsxs("section", { className: "space-y-1.5", children: [_jsx("div", { className: "font-medium text-muted-foreground", children: "Inputs" }), inputs.map((v) => (_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Label, { className: "w-24 shrink-0 truncate font-mono text-[11px]", title: v.name, children: v.name }), _jsx(Input, { value: seed[v.name] ?? (v.defaultValue != null ? String(v.defaultValue) : ''), onChange: (e) => setSeed((p) => ({ ...p, [v.name]: e.target.value })), placeholder: v.type ?? 'value', className: "h-7 flex-1 text-xs" })] }, v.name)))] })), _jsxs("section", { className: "space-y-1.5", children: [_jsxs("div", { className: "flex items-center gap-1.5 font-medium text-muted-foreground", children: [_jsx("span", { children: "Set variables" }), _jsxs("button", { type: "button", className: "ml-auto inline-flex items-center gap-0.5 rounded border px-1.5 py-0.5 text-[10px] hover:bg-muted/50", onClick: () => setScratch((p) => [...p, { k: '', v: '' }]), children: [_jsx(Plus, { className: "h-3 w-3" }), " Add"] })] }), scratch.length === 0 ? (_jsx("div", { className: "italic text-muted-foreground", children: "Override or inject any variable (wins over inputs and mocks at start)." })) : (scratch.map((row, i) => (_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx(Input, { value: row.k, onChange: (e) => setScratch((p) => p.map((r, j) => (j === i ? { ...r, k: e.target.value } : r))), placeholder: "name", className: "h-7 w-24 shrink-0 font-mono text-[11px]" }), _jsx("span", { className: "text-muted-foreground", children: "=" }), _jsx(Input, { value: row.v, onChange: (e) => setScratch((p) => p.map((r, j) => (j === i ? { ...r, v: e.target.value } : r))), placeholder: "value", className: "h-7 flex-1 text-xs" }), _jsx("button", { type: "button", className: "shrink-0 rounded p-1 text-muted-foreground hover:bg-muted/50 hover:text-rose-600", onClick: () => setScratch((p) => p.filter((_, j) => j !== i)), "aria-label": "Remove variable", children: _jsx(Trash2, { className: "h-3 w-3" }) })] }, i))))] }), mockNodes.length > 0 && (_jsxs("section", { className: "space-y-1.5", children: [_jsx("div", { className: "font-medium text-muted-foreground", children: "Mock outputs" }), mockNodes.map((m) => (_jsxs("div", { className: "space-y-0.5", children: [_jsxs(Label, { className: "flex items-baseline gap-1.5 text-[11px]", title: m.id, children: [_jsx("span", { className: "truncate font-medium", children: m.label }), _jsx("span", { className: "text-[9px] uppercase text-muted-foreground", children: m.type.replace(/_/g, ' ') }), m.outputs.length > 0 && (_jsxs("span", { className: "truncate font-mono text-[10px] text-violet-600", children: ["\u2192 ", m.outputs.join(', ')] }))] }), _jsx(Input, { value: mocks[m.id] ?? '', onChange: (e) => setMocks((p) => ({ ...p, [m.id]: e.target.value })), placeholder: m.type === 'script' && m.outputs.length ? `{ "${m.outputs[0]}": … }` : 'mocked result (JSON)', className: "h-7 w-full font-mono text-[11px]" })] }, m.id)))] })), snapshot && (_jsxs("section", { className: "space-y-1.5", children: [_jsx("div", { className: "font-medium text-muted-foreground", children: "Variables" }), Object.keys(snapshot.variables).length === 0 ? (_jsx("div", { className: "italic text-muted-foreground", children: "No variables set." })) : (_jsx("ul", { className: "space-y-1", children: Object.entries(snapshot.variables).map(([k, val]) => (_jsxs("li", { className: "flex items-baseline gap-1.5 rounded border bg-background px-1.5 py-1", children: [_jsx("span", { className: "font-mono text-[11px]", children: k }), _jsxs("span", { className: "truncate font-mono text-[10px] text-muted-foreground", children: ["= ", typeof val === 'object' ? JSON.stringify(val) : String(val)] })] }, k))) }))] })), snapshot && snapshot.steps.length > 0 && (_jsxs("section", { className: "space-y-1.5", children: [_jsx("div", { className: "font-medium text-muted-foreground", children: "Timeline" }), _jsx("ol", { className: "space-y-1", children: snapshot.steps.map((s) => (_jsxs("li", { className: "rounded border bg-background p-1.5", children: [_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx("span", { className: "font-mono text-[10px] text-muted-foreground", children: s.seq + 1 }), _jsx("span", { className: "truncate font-medium", children: s.label }), _jsx("span", { className: "text-[10px] uppercase text-muted-foreground", children: s.type }), _jsx("span", { className: cn('ml-auto rounded px-1 py-0.5 text-[9px] font-semibold uppercase', STATUS_TONE[s.status]), children: s.status })] }), s.note && _jsx("div", { className: "mt-0.5 text-[10px] text-muted-foreground", children: s.note }), s.error && _jsx("div", { className: "mt-0.5 text-[10px] text-rose-600", children: s.error }), s.wrote && (_jsxs("div", { className: "mt-0.5 truncate font-mono text-[10px] text-violet-600", children: ["\u2192 ", Object.keys(s.wrote).join(', ')] })), s.edges && s.edges.length > 0 && (_jsx("ul", { className: "mt-0.5 space-y-0.5", children: s.edges.map((ed) => (_jsxs("li", { className: "space-y-0.5", children: [_jsxs("div", { className: cn('flex items-center gap-1 font-mono text-[10px]', ed.selected ? 'text-sky-700' : 'text-muted-foreground'), children: [_jsx("span", { children: ed.selected ? '▶' : '·' }), _jsx("span", { className: "truncate", children: ed.isDefault ? 'else' : ed.condition }), _jsx("span", { className: cn('ml-auto', ed.error && 'text-rose-600'), children: ed.error ? 'error' : ed.result ? 'true' : 'false' })] }), ed.error && _jsx("div", { className: "pl-3 text-[10px] text-rose-600", children: ed.error })] }, ed.edgeId))) }))] }, s.seq))) })] })), !snapshot && !blocked && (_jsx("p", { className: "italic text-muted-foreground", children: "Press Run to simulate, or Step to walk node by node. Side effects are mocked \u2014 no backend is called." }))] })] }));
178
+ // When the run pauses at a `screen` node, preview the form the end user would
179
+ // see (the shared runtime renderer) instead of just showing "paused".
180
+ const screenPause = React.useMemo(() => {
181
+ if (snapshot?.status !== 'paused' || snapshot.pausedReason !== 'screen' || !snapshot.activeNodeId)
182
+ return null;
183
+ const node = nodes.find((n) => n.id === snapshot.activeNodeId);
184
+ return node ? { node, variables: snapshot.variables } : null;
185
+ }, [snapshot, nodes]);
186
+ // When paused at an approval node, offer its out-edge labels (approve /
187
+ // reject / revise) as decision buttons so the run resumes down ONE branch —
188
+ // letting an author walk a revise loop instead of fanning out to every edge.
189
+ const approvalPause = React.useMemo(() => {
190
+ if (snapshot?.status !== 'paused' || snapshot.pausedReason !== 'approval' || !snapshot.activeNodeId)
191
+ return null;
192
+ const node = nodes.find((n) => n.id === snapshot.activeNodeId);
193
+ if (!node)
194
+ return null;
195
+ const decisions = [
196
+ ...new Set(edges
197
+ .filter((e) => e.source === node.id && typeof e.label === 'string' && e.label.trim())
198
+ .map((e) => e.label.trim())),
199
+ ];
200
+ return { node, decisions };
201
+ }, [snapshot, nodes, edges]);
202
+ return (_jsxs("div", { className: "flex h-full flex-col text-xs", children: [_jsxs("div", { className: "flex items-center gap-1.5 border-b bg-muted/30 px-3 py-2", children: [_jsxs(Button, { size: "sm", className: "h-7 gap-1 px-2", onClick: onRun, disabled: blocked, children: [_jsx(Play, { className: "h-3.5 w-3.5" }), " Run"] }), _jsxs(Button, { size: "sm", variant: "outline", className: "h-7 gap-1 px-2", onClick: onStep, disabled: blocked || status === 'done' || status === 'error', children: [_jsx(StepForward, { className: "h-3.5 w-3.5" }), " Step"] }), status === 'paused' &&
203
+ (approvalPause && approvalPause.decisions.length > 0 ? (_jsx("div", { className: "flex items-center gap-1", children: approvalPause.decisions.map((d) => (_jsxs(Button, { size: "sm", variant: "outline", className: "h-7 gap-1 px-2 capitalize", onClick: () => onDecision(d), title: `Resume down the "${d}" branch`, children: [_jsx(ChevronRight, { className: "h-3.5 w-3.5" }), " ", d] }, d))) })) : (_jsxs(Button, { size: "sm", variant: "outline", className: "h-7 gap-1 px-2", onClick: onResume, children: [_jsx(ChevronRight, { className: "h-3.5 w-3.5" }), " Continue"] }))), _jsxs(Button, { size: "sm", variant: "ghost", className: "h-7 gap-1 px-2 text-muted-foreground", onClick: onReset, children: [_jsx(RotateCcw, { className: "h-3.5 w-3.5" }), " Reset"] }), _jsx("span", { className: cn('ml-auto rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase', STATUS_TONE[status === 'idle' || status === 'running' ? 'ok' : status] ?? 'bg-muted'), children: status })] }), _jsxs("div", { className: "min-h-0 flex-1 space-y-3 overflow-auto p-3", children: [validation && (validation.errors.length > 0 || validation.warnings.length > 0) && (_jsxs("div", { className: "space-y-1", children: [validation.errors.map((d, i) => (_jsxs("div", { className: "flex items-start gap-1.5 rounded border border-rose-200 bg-rose-50 px-2 py-1 text-rose-700", children: [_jsx(CircleAlert, { className: "mt-0.5 h-3 w-3 shrink-0" }), _jsx("span", { children: d.message })] }, `e${i}`))), validation.warnings.map((d, i) => (_jsxs("div", { className: "flex items-start gap-1.5 rounded border border-amber-200 bg-amber-50 px-2 py-1 text-amber-700", children: [_jsx(AlertTriangle, { className: "mt-0.5 h-3 w-3 shrink-0" }), _jsx("span", { children: d.message })] }, `w${i}`)))] })), screenPause && (_jsxs("section", { className: "space-y-1.5", children: [_jsx("div", { className: "font-medium text-muted-foreground", children: "Screen" }), _jsx(ScreenPreview, { node: screenPause.node, variables: screenPause.variables })] })), inputs.length > 0 && (_jsxs("section", { className: "space-y-1.5", children: [_jsx("div", { className: "font-medium text-muted-foreground", children: "Inputs" }), inputs.map((v) => (_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Label, { className: "w-24 shrink-0 truncate font-mono text-[11px]", title: v.name, children: v.name }), _jsx(Input, { value: seed[v.name] ?? (v.defaultValue != null ? String(v.defaultValue) : ''), onChange: (e) => setSeed((p) => ({ ...p, [v.name]: e.target.value })), placeholder: v.type ?? 'value', className: "h-7 flex-1 text-xs" })] }, v.name)))] })), _jsxs("section", { className: "space-y-1.5", children: [_jsxs("div", { className: "flex items-center gap-1.5 font-medium text-muted-foreground", children: [_jsx("span", { children: "Set variables" }), _jsxs("button", { type: "button", className: "ml-auto inline-flex items-center gap-0.5 rounded border px-1.5 py-0.5 text-[10px] hover:bg-muted/50", onClick: () => setScratch((p) => [...p, { k: '', v: '' }]), children: [_jsx(Plus, { className: "h-3 w-3" }), " Add"] })] }), scratch.length === 0 ? (_jsx("div", { className: "italic text-muted-foreground", children: "Override or inject any variable (wins over inputs and mocks at start)." })) : (scratch.map((row, i) => (_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx(Input, { value: row.k, onChange: (e) => setScratch((p) => p.map((r, j) => (j === i ? { ...r, k: e.target.value } : r))), placeholder: "name", className: "h-7 w-24 shrink-0 font-mono text-[11px]" }), _jsx("span", { className: "text-muted-foreground", children: "=" }), _jsx(Input, { value: row.v, onChange: (e) => setScratch((p) => p.map((r, j) => (j === i ? { ...r, v: e.target.value } : r))), placeholder: "value", className: "h-7 flex-1 text-xs" }), _jsx("button", { type: "button", className: "shrink-0 rounded p-1 text-muted-foreground hover:bg-muted/50 hover:text-rose-600", onClick: () => setScratch((p) => p.filter((_, j) => j !== i)), "aria-label": "Remove variable", children: _jsx(Trash2, { className: "h-3 w-3" }) })] }, i))))] }), mockNodes.length > 0 && (_jsxs("section", { className: "space-y-1.5", children: [_jsx("div", { className: "font-medium text-muted-foreground", children: "Mock outputs" }), mockNodes.map((m) => (_jsxs("div", { className: "space-y-0.5", children: [_jsxs(Label, { className: "flex items-baseline gap-1.5 text-[11px]", title: m.id, children: [_jsx("span", { className: "truncate font-medium", children: m.label }), _jsx("span", { className: "text-[9px] uppercase text-muted-foreground", children: m.type.replace(/_/g, ' ') }), m.outputs.length > 0 && (_jsxs("span", { className: "truncate font-mono text-[10px] text-violet-600", children: ["\u2192 ", m.outputs.join(', ')] }))] }), _jsx(Input, { value: mocks[m.id] ?? '', onChange: (e) => setMocks((p) => ({ ...p, [m.id]: e.target.value })), placeholder: m.type === 'script' && m.outputs.length ? `{ "${m.outputs[0]}": … }` : 'mocked result (JSON)', className: "h-7 w-full font-mono text-[11px]" })] }, m.id)))] })), snapshot && (_jsxs("section", { className: "space-y-1.5", children: [_jsx("div", { className: "font-medium text-muted-foreground", children: "Variables" }), Object.keys(snapshot.variables).length === 0 ? (_jsx("div", { className: "italic text-muted-foreground", children: "No variables set." })) : (_jsx("ul", { className: "space-y-1", children: Object.entries(snapshot.variables).map(([k, val]) => (_jsxs("li", { className: "flex items-baseline gap-1.5 rounded border bg-background px-1.5 py-1", children: [_jsx("span", { className: "font-mono text-[11px]", children: k }), _jsxs("span", { className: "truncate font-mono text-[10px] text-muted-foreground", children: ["= ", typeof val === 'object' ? JSON.stringify(val) : String(val)] })] }, k))) }))] })), snapshot && snapshot.steps.length > 0 && (_jsxs("section", { className: "space-y-1.5", children: [_jsx("div", { className: "font-medium text-muted-foreground", children: "Timeline" }), _jsx("ol", { className: "space-y-1", children: snapshot.steps.map((s) => (_jsxs("li", { className: "rounded border bg-background p-1.5", children: [_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx("span", { className: "font-mono text-[10px] text-muted-foreground", children: s.seq + 1 }), _jsx("span", { className: "truncate font-medium", children: s.label }), _jsx("span", { className: "text-[10px] uppercase text-muted-foreground", children: s.type }), _jsx("span", { className: cn('ml-auto rounded px-1 py-0.5 text-[9px] font-semibold uppercase', STATUS_TONE[s.status]), children: s.status })] }), s.note && _jsx("div", { className: "mt-0.5 text-[10px] text-muted-foreground", children: s.note }), s.error && _jsx("div", { className: "mt-0.5 text-[10px] text-rose-600", children: s.error }), s.wrote && (_jsxs("div", { className: "mt-0.5 truncate font-mono text-[10px] text-violet-600", children: ["\u2192 ", Object.keys(s.wrote).join(', ')] })), s.edges && s.edges.length > 0 && (_jsx("ul", { className: "mt-0.5 space-y-0.5", children: s.edges.map((ed) => (_jsxs("li", { className: "space-y-0.5", children: [_jsxs("div", { className: cn('flex items-center gap-1 font-mono text-[10px]', ed.selected ? 'text-sky-700' : 'text-muted-foreground'), children: [_jsx("span", { children: ed.selected ? '▶' : '·' }), _jsx("span", { className: "truncate", children: ed.isDefault ? 'else' : ed.condition }), _jsx("span", { className: cn('ml-auto', ed.error && 'text-rose-600'), children: ed.error ? 'error' : ed.result ? 'true' : 'false' })] }), ed.error && _jsx("div", { className: "pl-3 text-[10px] text-rose-600", children: ed.error })] }, ed.edgeId))) }))] }, s.seq))) })] })), !snapshot && !blocked && (_jsx("p", { className: "italic text-muted-foreground", children: "Press Run to simulate, or Step to walk node by node. Side effects are mocked \u2014 no backend is called." }))] })] }));
170
204
  }
@@ -19,6 +19,7 @@ import * as React from 'react';
19
19
  import { Badge, Button, cn, Popover, PopoverContent, PopoverTrigger, } from '@object-ui/components';
20
20
  import { GripVertical, Plus, ChevronDown, ChevronRight, Trash2, ArrowUp, ArrowDown, FolderPlus, FolderInput, ChevronsDownUp, ChevronsUpDown, CheckSquare, GitCompareArrows, Sparkles, X, } from 'lucide-react';
21
21
  import { requestAssistantOpen } from '../../../assistant/assistantBus';
22
+ import { useAiSurfaceEnabled } from '../../../hooks/useAiSurface';
22
23
  import { readFields, writeFields, newField, toFieldName, groupEntries, readGroups, addGroup, renameGroup, removeGroup, moveGroup, clearFieldGroup, diffFields, } from './object-fields-io';
23
24
  import { FIELD_TYPE_META, TYPES_BY_CATEGORY, CATEGORY_LABEL_EN, CATEGORY_LABEL_ZH, CATEGORY_TONE, } from './field-types';
24
25
  import { FieldStub } from './FieldStub';
@@ -30,6 +31,10 @@ const typeLabel = (meta, locale) => meta ? (isZh(locale) ? meta.labelZh : meta.l
30
31
  const categoryLabel = (cat, locale) => (isZh(locale) ? CATEGORY_LABEL_ZH : CATEGORY_LABEL_EN)[cat];
31
32
  export function ObjectFormCanvas({ objectName, draft, baseline, onPatch, selection, onSelectionChange, locale, }) {
32
33
  const readOnly = !onPatch;
34
+ // The "Ask AI" affordances arm the global chat FAB (requestAssistantOpen).
35
+ // Hide them when the runtime serves no AI — otherwise they'd dead-end with no
36
+ // FAB to open. Same server-pushed signal the FAB itself gates on.
37
+ const { enabled: aiEnabled } = useAiSurfaceEnabled();
33
38
  const view = React.useMemo(() => readFields(draft.fields), [draft]);
34
39
  /* ─── Review/diff mode — draft vs last published ─── */
35
40
  const diff = React.useMemo(() => (baseline ? diffFields(baseline.fields, draft.fields) : null), [baseline, draft]);
@@ -311,14 +316,14 @@ export function ObjectFormCanvas({ objectName, draft, baseline, onPatch, selecti
311
316
  // Section chrome (headers, collapse, drop-to-assign) only appears once
312
317
  // groups exist — otherwise the canvas stays a flat field list.
313
318
  const showSectionChrome = hasGroups || groups.length > 1;
314
- return (_jsxs("div", { className: "h-full overflow-auto bg-muted/20", onClick: handleBgClick, "data-object-name": objectName, children: [!readOnly && multiSel.size > 0 && (_jsx(BulkActionBar, { count: multiSel.size, groups: declaredGroups, onMoveToGroup: bulkSetGroup, onDelete: bulkDelete, onClear: clearMulti, locale: locale })), _jsxs("div", { className: "mx-auto max-w-3xl px-6 py-6 space-y-4", onClick: handleBgClick, children: [!emptyState && (_jsx(CanvasToolbar, { fieldCount: view.entries.length, requiredCount: requiredCount, sectionCount: declaredGroups.length, allCollapsed: allCollapsed, onToggleAll: showSectionChrome ? () => setAllCollapsed(!allCollapsed) : undefined, reviewAvailable: changeCount > 0, reviewing: reviewing, diffCounts: diff?.counts, onToggleReview: () => setReviewMode((v) => !v), locale: locale })), emptyState ? (_jsx(EmptyCanvas, { onAdd: readOnly ? undefined : addField, locale: locale })) : (_jsx("div", { className: "space-y-5", children: groups.map((g) => {
319
+ return (_jsxs("div", { className: "h-full overflow-auto bg-muted/20", onClick: handleBgClick, "data-object-name": objectName, children: [!readOnly && multiSel.size > 0 && (_jsx(BulkActionBar, { count: multiSel.size, groups: declaredGroups, onMoveToGroup: bulkSetGroup, onDelete: bulkDelete, onClear: clearMulti, locale: locale })), _jsxs("div", { className: "mx-auto max-w-3xl px-6 py-6 space-y-4", onClick: handleBgClick, children: [!emptyState && (_jsx(CanvasToolbar, { fieldCount: view.entries.length, requiredCount: requiredCount, sectionCount: declaredGroups.length, allCollapsed: allCollapsed, onToggleAll: showSectionChrome ? () => setAllCollapsed(!allCollapsed) : undefined, reviewAvailable: changeCount > 0, reviewing: reviewing, diffCounts: diff?.counts, onToggleReview: () => setReviewMode((v) => !v), locale: locale })), emptyState ? (_jsx(EmptyCanvas, { onAdd: readOnly ? undefined : addField, locale: locale, aiEnabled: aiEnabled })) : (_jsx("div", { className: "space-y-5", children: groups.map((g) => {
315
320
  const declaredIdx = g.key
316
321
  ? declaredGroups.findIndex((d) => d.key === g.key)
317
322
  : -1;
318
323
  return (_jsxs(GroupSection, { groupKey: g.key, label: g.key === null ? t('designer.canvas.ungrouped', locale) : g.label, count: g.entries.length, showHeader: showSectionChrome, collapsed: !!collapsed[collapseKey(g.key)], onToggleCollapse: () => toggleCollapse(g.key), readOnly: readOnly, locale: locale, canMoveUp: declaredIdx > 0, canMoveDown: declaredIdx >= 0 && declaredIdx < declaredGroups.length - 1, onRename: g.key ? (label) => renameSection(g.key, label) : undefined, onRemove: g.key ? () => removeSection(g.key) : undefined, onMove: g.key ? (dir) => moveSection(g.key, dir) : undefined, onAddField: readOnly ? undefined : (type) => addField(type, g.key), onDropField: readOnly ? undefined : moveToGroup, children: [g.entries.map((entry) => (_jsx(FieldRow, { entry: entry, selected: entry.name === selectedName, multiSelected: multiSel.has(entry.name), diffStatus: statusOf(entry.name), changedKeys: changedKeysOf(entry.name), readOnly: readOnly, locale: locale, onClick: (e) => handleRowClick(entry, e), onReorder: readOnly ? undefined : reorderField, onRenameLabel: readOnly ? undefined : renameLabel, onMoveOffset: readOnly ? undefined : (dir) => moveFieldByOffset(entry.name, dir) }, entry.name))), g.entries.length === 0 && (_jsx("div", { className: "rounded-md border border-dashed bg-background/40 px-3 py-4 text-center text-[11px] text-muted-foreground", children: readOnly
319
324
  ? t('designer.canvas.emptySection', locale)
320
325
  : t('designer.canvas.dropHint', locale) }))] }, g.key ?? '__ungrouped__'));
321
- }) })), reviewing && diff && diff.removed.length > 0 && (_jsxs("div", { className: "space-y-2.5", children: [_jsx("div", { className: "text-[11px] font-medium uppercase tracking-wider text-destructive/80 pl-1", children: t('designer.canvas.diffRemoved', locale) }), diff.removed.map((entry) => (_jsx(GhostFieldRow, { entry: entry, locale: locale }, entry.name)))] })), !emptyState && !readOnly && (_jsxs("div", { className: "flex items-center gap-2 pt-1", children: [_jsx(AddFieldButton, { onPick: (type) => addField(type), locale: locale }), _jsxs(Button, { variant: "ghost", size: "sm", className: "gap-1.5 text-muted-foreground hover:text-foreground", onClick: addSection, children: [_jsx(FolderPlus, { className: "h-3.5 w-3.5" }), t('designer.canvas.addSection', locale)] }), _jsxs(Button, { variant: "ghost", size: "sm", className: "gap-1.5 ml-auto text-primary/80 hover:text-primary", onClick: () => requestAssistantOpen(), children: [_jsx(Sparkles, { className: "h-3.5 w-3.5" }), t('designer.canvas.askAi', locale)] })] }))] })] }));
326
+ }) })), reviewing && diff && diff.removed.length > 0 && (_jsxs("div", { className: "space-y-2.5", children: [_jsx("div", { className: "text-[11px] font-medium uppercase tracking-wider text-destructive/80 pl-1", children: t('designer.canvas.diffRemoved', locale) }), diff.removed.map((entry) => (_jsx(GhostFieldRow, { entry: entry, locale: locale }, entry.name)))] })), !emptyState && !readOnly && (_jsxs("div", { className: "flex items-center gap-2 pt-1", children: [_jsx(AddFieldButton, { onPick: (type) => addField(type), locale: locale }), _jsxs(Button, { variant: "ghost", size: "sm", className: "gap-1.5 text-muted-foreground hover:text-foreground", onClick: addSection, children: [_jsx(FolderPlus, { className: "h-3.5 w-3.5" }), t('designer.canvas.addSection', locale)] }), aiEnabled && (_jsxs(Button, { variant: "ghost", size: "sm", className: "gap-1.5 ml-auto text-primary/80 hover:text-primary", onClick: () => requestAssistantOpen(), children: [_jsx(Sparkles, { className: "h-3.5 w-3.5" }), t('designer.canvas.askAi', locale)] }))] }))] })] }));
322
327
  }
323
328
  /* ─────────────── Review toolbar ─────────────── */
324
329
  function CanvasToolbar({ fieldCount, requiredCount, sectionCount, allCollapsed, onToggleAll, reviewAvailable, reviewing, diffCounts, onToggleReview, locale, }) {
@@ -509,8 +514,8 @@ function FieldRow({ entry, selected, multiSelected, diffStatus, changedKeys, rea
509
514
  ? tFormat('designer.canvas.diffChangedKeys', locale, { keys: changedKeys.join(', ') })
510
515
  : undefined, children: t('designer.canvas.diffChanged', locale) })), _jsx(Badge, { variant: "outline", className: cn('text-[10px] font-medium', tone.badge), children: typeLabel(meta, locale) ?? typeStr })] })] }), description && (_jsx("div", { className: "text-[11px] text-muted-foreground mb-1.5 line-clamp-1", children: description })), _jsx(FieldStub, { type: typeStr, label: label, placeholder: placeholder, options: options, referenceTo: referenceTo, formula: formula, locale: locale })] }), dropZone === 'after' && (_jsx("div", { className: "absolute left-0 right-0 -bottom-1 h-0.5 bg-primary rounded-full" }))] }));
511
516
  }
512
- function EmptyCanvas({ onAdd, locale }) {
513
- return (_jsxs("div", { className: "rounded-lg border-2 border-dashed bg-background py-16 px-6 text-center space-y-3", children: [_jsx("div", { className: "text-sm font-medium", children: t('designer.canvas.noFields', locale) }), _jsx("div", { className: "text-xs text-muted-foreground", children: t('designer.canvas.noFieldsHint', locale) }), onAdd && (_jsxs("div", { className: "pt-2 flex items-center justify-center gap-2", children: [_jsx(AddFieldButton, { onPick: onAdd, locale: locale }), _jsxs(Button, { variant: "ghost", size: "sm", className: "gap-1.5 text-primary/80 hover:text-primary", onClick: () => requestAssistantOpen(), children: [_jsx(Sparkles, { className: "h-3.5 w-3.5" }), t('designer.canvas.askAiGenerate', locale)] })] }))] }));
517
+ function EmptyCanvas({ onAdd, locale, aiEnabled, }) {
518
+ return (_jsxs("div", { className: "rounded-lg border-2 border-dashed bg-background py-16 px-6 text-center space-y-3", children: [_jsx("div", { className: "text-sm font-medium", children: t('designer.canvas.noFields', locale) }), _jsx("div", { className: "text-xs text-muted-foreground", children: t('designer.canvas.noFieldsHint', locale) }), onAdd && (_jsxs("div", { className: "pt-2 flex items-center justify-center gap-2", children: [_jsx(AddFieldButton, { onPick: onAdd, locale: locale }), aiEnabled && (_jsxs(Button, { variant: "ghost", size: "sm", className: "gap-1.5 text-primary/80 hover:text-primary", onClick: () => requestAssistantOpen(), children: [_jsx(Sparkles, { className: "h-3.5 w-3.5" }), t('designer.canvas.askAiGenerate', locale)] }))] }))] }));
514
519
  }
515
520
  function AddFieldButton({ onPick, compact, locale }) {
516
521
  const [open, setOpen] = React.useState(false);