@object-ui/app-shell 7.1.0 → 7.3.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 (107) hide show
  1. package/CHANGELOG.md +320 -0
  2. package/dist/components/ManagedByBadge.js +1 -1
  3. package/dist/console/AppContent.js +9 -15
  4. package/dist/console/ConsoleShell.d.ts +16 -0
  5. package/dist/console/ConsoleShell.js +43 -2
  6. package/dist/console/ai/AiChatPage.js +64 -14
  7. package/dist/console/ai/BuildDebugDrawer.d.ts +20 -0
  8. package/dist/console/ai/BuildDebugDrawer.js +75 -0
  9. package/dist/console/ai/buildDebugApi.d.ts +94 -0
  10. package/dist/console/ai/buildDebugApi.js +16 -0
  11. package/dist/console/home/HomeLayout.js +5 -7
  12. package/dist/console/home/HomePage.js +1 -9
  13. package/dist/console/organizations/CreateWorkspaceDialog.js +15 -1
  14. package/dist/console/organizations/OrganizationsPage.js +32 -4
  15. package/dist/console/organizations/manage/OrganizationLayout.js +1 -1
  16. package/dist/console/organizations/provisionEnvironment.d.ts +53 -0
  17. package/dist/console/organizations/provisionEnvironment.js +64 -0
  18. package/dist/environment/EnvironmentEntitlementDialog.d.ts +34 -0
  19. package/dist/environment/EnvironmentEntitlementDialog.js +37 -0
  20. package/dist/environment/EnvironmentListToolbar.d.ts +33 -0
  21. package/dist/environment/EnvironmentListToolbar.js +59 -0
  22. package/dist/environment/entitlements.d.ts +90 -0
  23. package/dist/environment/entitlements.js +91 -0
  24. package/dist/environment/useEnvironmentEntitlements.d.ts +32 -0
  25. package/dist/environment/useEnvironmentEntitlements.js +108 -0
  26. package/dist/hooks/useActionModal.js +15 -1
  27. package/dist/hooks/useAiSurface.d.ts +59 -0
  28. package/dist/hooks/useAiSurface.js +78 -0
  29. package/dist/hooks/useConsoleActionRuntime.d.ts +3 -0
  30. package/dist/hooks/useConsoleActionRuntime.js +36 -8
  31. package/dist/index.d.ts +3 -1
  32. package/dist/index.js +5 -1
  33. package/dist/layout/AppHeader.js +30 -5
  34. package/dist/layout/ConsoleFloatingChatbot.js +22 -4
  35. package/dist/layout/ConsoleLayout.js +5 -6
  36. package/dist/layout/ContextSelectors.js +0 -19
  37. package/dist/layout/WorkspaceSwitcher.d.ts +14 -0
  38. package/dist/layout/WorkspaceSwitcher.js +76 -0
  39. package/dist/preview/DraftPreviewBar.js +20 -7
  40. package/dist/providers/ExpressionProvider.js +9 -3
  41. package/dist/utils/index.d.ts +2 -2
  42. package/dist/utils/index.js +1 -1
  43. package/dist/utils/managedByEmptyState.d.ts +1 -1
  44. package/dist/utils/managedByEmptyState.js +20 -2
  45. package/dist/utils/recordFormNavigation.d.ts +60 -0
  46. package/dist/utils/recordFormNavigation.js +35 -0
  47. package/dist/utils/resolvePageVarTokens.d.ts +31 -0
  48. package/dist/utils/resolvePageVarTokens.js +72 -0
  49. package/dist/views/CreateViewDialog.js +14 -1
  50. package/dist/views/ObjectView.js +27 -13
  51. package/dist/views/metadata-admin/AssignedUsersSection.d.ts +28 -0
  52. package/dist/views/metadata-admin/AssignedUsersSection.js +151 -0
  53. package/dist/views/metadata-admin/PackagesPage.d.ts +5 -0
  54. package/dist/views/metadata-admin/PackagesPage.js +49 -4
  55. package/dist/views/metadata-admin/PermissionMatrixEditor.js +2 -1
  56. package/dist/views/metadata-admin/ResourceEditPage.js +36 -4
  57. package/dist/views/metadata-admin/ResourceListPage.js +25 -10
  58. package/dist/views/metadata-admin/StudioHomePage.js +1 -5
  59. package/dist/views/metadata-admin/createBody.d.ts +26 -0
  60. package/dist/views/metadata-admin/createBody.js +30 -0
  61. package/dist/views/metadata-admin/i18n.js +20 -2
  62. package/dist/views/metadata-admin/inspectors/DatasetDefaultInspector.d.ts +8 -0
  63. package/dist/views/metadata-admin/inspectors/DatasetDefaultInspector.js +17 -3
  64. package/dist/views/metadata-admin/inspectors/FlowEdgeInspector.js +16 -2
  65. package/dist/views/metadata-admin/inspectors/FlowExprIssue.d.ts +21 -0
  66. package/dist/views/metadata-admin/inspectors/FlowExprIssue.js +13 -0
  67. package/dist/views/metadata-admin/inspectors/FlowKeyValueField.d.ts +20 -2
  68. package/dist/views/metadata-admin/inspectors/FlowKeyValueField.js +71 -28
  69. package/dist/views/metadata-admin/inspectors/FlowNodeConfigField.d.ts +4 -1
  70. package/dist/views/metadata-admin/inspectors/FlowNodeConfigField.js +24 -9
  71. package/dist/views/metadata-admin/inspectors/FlowNodeInspector.js +15 -3
  72. package/dist/views/metadata-admin/inspectors/FlowObjectListField.d.ts +4 -1
  73. package/dist/views/metadata-admin/inspectors/FlowObjectListField.js +8 -3
  74. package/dist/views/metadata-admin/inspectors/VariableTextInput.d.ts +47 -0
  75. package/dist/views/metadata-admin/inspectors/VariableTextInput.js +95 -0
  76. package/dist/views/metadata-admin/inspectors/datasetFilterCondition.js +6 -1
  77. package/dist/views/metadata-admin/inspectors/flow-node-config.d.ts +16 -1
  78. package/dist/views/metadata-admin/inspectors/flow-node-config.js +21 -10
  79. package/dist/views/metadata-admin/inspectors/flow-ref-check.d.ts +39 -0
  80. package/dist/views/metadata-admin/inspectors/flow-ref-check.js +114 -0
  81. package/dist/views/metadata-admin/inspectors/flow-scope.d.ts +109 -0
  82. package/dist/views/metadata-admin/inspectors/flow-scope.js +199 -0
  83. package/dist/views/metadata-admin/inspectors/useDatasetFields.d.ts +14 -3
  84. package/dist/views/metadata-admin/inspectors/useDatasetFields.js +0 -0
  85. package/dist/views/metadata-admin/inspectors/useFlowScope.d.ts +23 -0
  86. package/dist/views/metadata-admin/inspectors/useFlowScope.js +45 -0
  87. package/dist/views/metadata-admin/package-scope.d.ts +9 -19
  88. package/dist/views/metadata-admin/package-scope.js +11 -25
  89. package/dist/views/metadata-admin/preview-registry.d.ts +12 -0
  90. package/dist/views/metadata-admin/previews/FlowCanvas.d.ts +22 -3
  91. package/dist/views/metadata-admin/previews/FlowCanvas.js +45 -6
  92. package/dist/views/metadata-admin/previews/FlowPreview.d.ts +1 -1
  93. package/dist/views/metadata-admin/previews/FlowPreview.js +42 -30
  94. package/dist/views/metadata-admin/previews/ObjectFormCanvas.js +9 -4
  95. package/dist/views/metadata-admin/previews/ProblemsPanel.d.ts +18 -0
  96. package/dist/views/metadata-admin/previews/ProblemsPanel.js +27 -0
  97. package/dist/views/metadata-admin/previews/ReportPreview.d.ts +9 -8
  98. package/dist/views/metadata-admin/previews/ReportPreview.js +33 -16
  99. package/dist/views/metadata-admin/previews/flow-canvas-parts.d.ts +9 -1
  100. package/dist/views/metadata-admin/previews/flow-canvas-parts.js +5 -3
  101. package/dist/views/metadata-admin/previews/flow-expr-problems.d.ts +19 -0
  102. package/dist/views/metadata-admin/previews/flow-expr-problems.js +97 -0
  103. package/dist/views/metadata-admin/previews/flow-problems.d.ts +84 -0
  104. package/dist/views/metadata-admin/previews/flow-problems.js +209 -0
  105. package/dist/views/metadata-admin/previews/simulator/flow-sim-types.d.ts +9 -0
  106. package/dist/views/metadata-admin/previews/simulator/flow-sim-validate.js +4 -2
  107. package/package.json +38 -38
@@ -123,11 +123,13 @@ function MeasureFormatField({ measure, onPatch, disabled }) {
123
123
  const sample = formatMeasure(kind === 'percent' ? 0.1234 : 1234.5, measure.format, measure.currency);
124
124
  return (_jsxs("div", { className: "space-y-1.5", children: [_jsxs("div", { className: "grid grid-cols-2 gap-1.5", children: [_jsx(InspectorSelectField, { label: "Display format", value: kind, options: FORMAT_KIND_OPTIONS, onCommit: (v) => apply(v, decimals, currency), disabled: disabled }), kind !== 'raw' && (_jsx(InspectorSelectField, { label: "Decimals", value: String(decimals), options: DECIMALS_OPTIONS, onCommit: (v) => apply(kind, parseInt(v, 10) || 0, currency), disabled: disabled }))] }), kind === 'currency' && (_jsx(InspectorSelectField, { label: "Currency", value: currency, options: CURRENCY_OPTIONS, onCommit: (v) => apply(kind, decimals, v), disabled: disabled })), kind !== 'raw' && (_jsxs("p", { className: "text-[10px] text-muted-foreground", children: ["Sample: ", _jsx("span", { className: "font-mono tabular-nums", children: sample })] }))] }));
125
125
  }
126
- /** The relationship prefix of a `relationship.field` path that isn't yet in `include`, else null. */
126
+ /** The relationship PATH of a `relationship[.relationship].field` reference (all
127
+ * segments but the final column) that isn't yet in `include`, else null. ADR-0071
128
+ * multi-hop: `account.owner.region` → `account.owner`. */
127
129
  function missingRelationship(field, include) {
128
130
  if (!field || !field.includes('.'))
129
131
  return null;
130
- const rel = field.split('.')[0];
132
+ const rel = field.slice(0, field.lastIndexOf('.'));
131
133
  return rel && !include.includes(rel) ? rel : null;
132
134
  }
133
135
  /** Inline author-time warning: a `relationship.field` whose join isn't declared in `include`. */
@@ -146,6 +148,18 @@ function DatasetFilterField({ label, help, value, onCommit, fields, disabled })
146
148
  const count = group.conditions.length;
147
149
  return (_jsxs("div", { className: "space-y-1", children: [_jsx(Label, { className: "text-xs text-muted-foreground", children: label }), !representable ? (_jsxs("p", { className: "rounded-md border border-dashed bg-muted/30 px-2.5 py-1.5 text-[11px] text-muted-foreground", children: ["Advanced filter (nested / OR) \u2014 edit it in the ", _jsx("span", { className: "font-medium", children: "Source" }), " tab."] })) : (_jsxs(Popover, { children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs(Button, { type: "button", variant: "outline", size: "sm", disabled: disabled, className: "h-8 w-full justify-between text-xs font-normal", children: [_jsx("span", { className: "truncate text-left", children: count ? `${count} condition${count === 1 ? '' : 's'}` : _jsx("span", { className: "text-muted-foreground", children: "+ Add filter\u2026" }) }), _jsx(ChevronDown, { className: "h-3.5 w-3.5 opacity-60 shrink-0" })] }) }), _jsx(PopoverContent, { align: "start", className: "w-[440px] max-w-[90vw] p-3", children: fields.length === 0 ? (_jsx("p", { className: "text-xs text-muted-foreground", children: "Pick a base object to add filter conditions." })) : (_jsx(FilterBuilder, { fields: fields, value: group, onChange: (g) => onCommit(groupToCondition(g)) })) })] })), help && _jsx("p", { className: "text-[10px] text-muted-foreground", children: help })] }));
148
150
  }
151
+ /**
152
+ * Patch for a base-object change. A dataset's joins (`include`), `dimensions`,
153
+ * `measures`, and `filter` all reference the OLD object's fields, so a real
154
+ * object change re-bases the dataset and clears them — preventing stale field
155
+ * refs from silently producing broken/ambiguous queries. Selecting the SAME
156
+ * object is a no-op (only sets `object`).
157
+ */
158
+ export function objectChangePatch(next, current) {
159
+ if (next === current)
160
+ return { object: next };
161
+ return { object: next, include: [], dimensions: [], measures: [], filter: undefined };
162
+ }
149
163
  export function DatasetDefaultInspector({ draft, onPatch, readOnly, name }) {
150
164
  const label = typeof draft.label === 'string' ? draft.label : '';
151
165
  const description = typeof draft.description === 'string' ? draft.description : '';
@@ -203,7 +217,7 @@ export function DatasetDefaultInspector({ draft, onPatch, readOnly, name }) {
203
217
  if (createMode && !nameTouched.current)
204
218
  patch.name = toFieldName(v);
205
219
  onPatch(patch);
206
- }, disabled: readOnly }), _jsx(InspectorTextField, { label: "Description", value: description, onCommit: (v) => onPatch({ description: v }), disabled: readOnly }), _jsx(InspectorComboField, { label: "Base object", value: object, onCommit: (v) => onPatch({ object: v }), options: objectComboOptions, loading: objectsLoading, placeholder: "Select an object\u2026", searchPlaceholder: "Search objects\u2026", disabled: readOnly, mono: true }), _jsxs("div", { className: "border-t pt-3 space-y-1.5", children: [_jsx(SectionHeader, { title: "Included relationships", count: include.length, addLabel: "Add", onAdd: readOnly ? undefined : () => onPatch({ include: appendArray(include, '') }) }), include.length === 0 ? (_jsxs("p", { className: "rounded-md border border-dashed bg-muted/30 px-3 py-2 text-center text-[11px] text-muted-foreground", children: ["No joins. Add a relationship (a lookup field on ", _jsx("code", { children: baseLabel || 'the base object' }), ") to use ", _jsx("code", { children: "relationship.field" }), " dimensions/measures."] })) : (include.map((rel, i) => (_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx(InspectorComboField, { value: rel, onCommit: (v) => onPatch({ include: include.map((r, idx) => (idx === i ? v : r)) }), options: relationshipComboOptions, loading: catalogLoading, placeholder: "Select a relationship\u2026", searchPlaceholder: "Search relationships\u2026", disabled: readOnly, mono: true }), !readOnly && (_jsx(Button, { type: "button", variant: "ghost", size: "sm", className: "h-7 w-7 shrink-0 p-0", onClick: () => onPatch({ include: spliceArray(include, i, null) }), "aria-label": "Remove relationship", children: _jsx(X, { className: "h-3.5 w-3.5" }) }))] }, i)))), object && include.length > 0 && (_jsxs("div", { className: "flex flex-wrap items-center gap-x-1 gap-y-0.5 pt-0.5 text-[10px] text-muted-foreground", children: [_jsx("span", { className: "font-mono font-medium", children: baseLabel }), include.map((rel, i) => {
220
+ }, disabled: readOnly }), _jsx(InspectorTextField, { label: "Description", value: description, onCommit: (v) => onPatch({ description: v }), disabled: readOnly }), _jsx(InspectorComboField, { label: "Base object", value: object, onCommit: (v) => onPatch(objectChangePatch(v, object)), options: objectComboOptions, loading: objectsLoading, placeholder: "Select an object\u2026", searchPlaceholder: "Search objects\u2026", disabled: readOnly, mono: true }), object && (dimensions.length > 0 || measures.length > 0 || include.length > 0 || !!datasetFilter) && (_jsx("p", { className: "text-[10px] text-muted-foreground", children: "Changing the base object clears its dimensions, measures, joins & filters." })), _jsxs("div", { className: "border-t pt-3 space-y-1.5", children: [_jsx(SectionHeader, { title: "Included relationships", count: include.length, addLabel: "Add", onAdd: readOnly ? undefined : () => onPatch({ include: appendArray(include, '') }) }), include.length === 0 ? (_jsxs("p", { className: "rounded-md border border-dashed bg-muted/30 px-3 py-2 text-center text-[11px] text-muted-foreground", children: ["No joins. Add a relationship (a lookup field on ", _jsx("code", { children: baseLabel || 'the base object' }), ") to use ", _jsx("code", { children: "relationship.field" }), " dimensions/measures."] })) : (include.map((rel, i) => (_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx(InspectorComboField, { value: rel, onCommit: (v) => onPatch({ include: include.map((r, idx) => (idx === i ? v : r)) }), options: relationshipComboOptions, loading: catalogLoading, placeholder: "Select a relationship\u2026", searchPlaceholder: "Search relationships\u2026", disabled: readOnly, mono: true }), !readOnly && (_jsx(Button, { type: "button", variant: "ghost", size: "sm", className: "h-7 w-7 shrink-0 p-0", onClick: () => onPatch({ include: spliceArray(include, i, null) }), "aria-label": "Remove relationship", children: _jsx(X, { className: "h-3.5 w-3.5" }) }))] }, i)))), object && include.length > 0 && (_jsxs("div", { className: "flex flex-wrap items-center gap-x-1 gap-y-0.5 pt-0.5 text-[10px] text-muted-foreground", children: [_jsx("span", { className: "font-mono font-medium", children: baseLabel }), include.map((rel, i) => {
207
221
  const r = relationships.find((x) => x.name === rel);
208
222
  return (_jsxs("span", { className: "inline-flex items-center gap-1", children: [_jsx(ArrowRight, { className: "h-3 w-3 opacity-60" }), _jsxs("span", { className: "font-mono", children: [rel, r?.referenceTo ? ` (${r.referenceTo})` : ''] })] }, i));
209
223
  })] }))] }), _jsx("div", { className: "border-t pt-3", children: _jsx(DatasetFilterField, { label: "Scope filter", help: "Intrinsic scope, ANDed into every query (e.g. exclude soft-deleted records).", value: datasetFilter, onCommit: (fc) => onPatch({ filter: fc }), fields: filterFields, disabled: readOnly }) }), _jsxs("div", { className: "border-t pt-3 space-y-2", children: [_jsx(SectionHeader, { title: "Dimensions", count: dimensions.length, addLabel: "Add dimension", onAdd: readOnly ? undefined : () => onPatch({ dimensions: appendArray(dimensions, { name: '', field: '', type: 'string' }) }) }), dimensions.map((d, i) => (_jsxs("div", { className: "rounded-md border p-2 space-y-1.5", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("span", { className: "text-[11px] font-medium text-muted-foreground", children: ["Dimension ", i + 1] }), !readOnly && (_jsx(Button, { type: "button", variant: "ghost", size: "sm", "aria-label": "Remove dimension", title: "Remove dimension", className: "h-6 w-6 shrink-0 p-0 text-muted-foreground hover:text-destructive", onClick: () => onPatch({ dimensions: spliceArray(dimensions, i, null) }), children: _jsx(Trash2, { className: "h-3.5 w-3.5" }) }))] }), _jsx(InspectorTextField, { label: "Name", value: d.name ?? '', onCommit: (v) => patchDimension(i, { name: v }), placeholder: "e.g. region", disabled: readOnly, mono: true }), _jsx(InspectorComboField, { label: "Field", value: d.field ?? '', onCommit: (v) => pickDimensionField(i, v), options: fieldComboOptions, loading: catalogLoading, placeholder: "field or relationship.field", searchPlaceholder: "Search fields\u2026", disabled: readOnly, mono: true }), (() => { const rel = missingRelationship(d.field, include); return rel ? _jsx(RelWarning, { rel: rel, disabled: readOnly, onAdd: () => onPatch({ include: appendArray(include, rel) }) }) : null; })(), _jsx(InspectorSelectField, { label: "Type", value: d.type, options: DIMENSION_TYPE_OPTIONS, onCommit: (v) => patchDimension(i, { type: v }), disabled: readOnly }), _jsxs(Advanced, { children: [_jsx(InspectorTextField, { label: "Label (optional)", value: d.label ?? '', onCommit: (v) => patchDimension(i, { label: v || undefined }), placeholder: d.name || 'Display label', disabled: readOnly }), d.type === 'date' && (_jsx(InspectorSelectField, { label: "Date bucket", value: d.dateGranularity ?? '', options: DATE_GRANULARITY_OPTIONS, onCommit: (v) => patchDimension(i, { dateGranularity: v || undefined }), disabled: readOnly }))] })] }, i)))] }), _jsxs("div", { className: "border-t pt-3 space-y-2", children: [_jsx(SectionHeader, { title: "Measures", count: measures.length, addLabel: "Add measure", onAdd: readOnly ? undefined : () => onPatch({ measures: appendArray(measures, { name: '', aggregate: 'sum', field: '' }) }) }), measures.map((m, i) => {
@@ -4,6 +4,9 @@ import { InspectorShell, InspectorTextField, InspectorSelectField, InspectorChec
4
4
  import { Label } from '@object-ui/components';
5
5
  import { edgeKey, conditionText } from '../previews/flow-canvas-layout';
6
6
  import { validateExpressionClient } from './expression-validate';
7
+ import { useFlowScope } from './useFlowScope';
8
+ import { VariableTextInput } from './VariableTextInput';
9
+ import { findUnknownRefs, scopeRoots, describeUnknownRefs } from './flow-ref-check';
7
10
  /** Read-only display of an edge endpoint (source / target node id). */
8
11
  function EndpointRow({ label, value }) {
9
12
  return (_jsxs("div", { className: "space-y-1", children: [_jsx(Label, { className: "text-xs text-muted-foreground", children: label }), _jsx("div", { className: "flex h-8 items-center rounded border bg-muted/30 px-2 font-mono text-sm text-muted-foreground", children: value })] }));
@@ -12,6 +15,9 @@ export function FlowEdgeInspector({ selection, draft, onPatch, onClearSelection,
12
15
  const edges = Array.isArray(draft.edges) ? draft.edges : [];
13
16
  const index = edges.findIndex((e, i) => edgeKey(e, i) === selection.id);
14
17
  const edge = index >= 0 ? edges[index] : null;
18
+ // References available on this edge are those in scope at its SOURCE node
19
+ // (#1934). Called unconditionally — `edge?.source` is undefined when missing.
20
+ const { groups: scopeGroups } = useFlowScope(draft, edge?.source);
15
21
  if (!edge) {
16
22
  return (_jsx(InspectorShell, { kindLabel: t('engine.inspector.flowEdge.kind', locale), title: selection.label ?? selection.id, onClose: onClearSelection, closeLabel: t('engine.inspector.flowEdge.close', locale), children: _jsx(InspectorEmptyState, { message: t('engine.inspector.flowEdge.missing', locale) }) }));
17
23
  }
@@ -109,11 +115,19 @@ export function FlowEdgeInspector({ selection, draft, onPatch, onClearSelection,
109
115
  // Picking a branch writes the matching label; "Custom" keeps the
110
116
  // free-text label the author typed below.
111
117
  onCommit: (v) => { if (v)
112
- patchEdge({ label: v }); }, disabled: readOnly })), _jsx(InspectorTextField, { label: t('engine.inspector.flowEdge.label', locale), value: edge.label ?? '', onCommit: (v) => patchEdge({ label: v }), placeholder: t('engine.inspector.flowEdge.labelHint', locale), disabled: readOnly || isDefault }), _jsx(InspectorTextField, { label: t('engine.inspector.flowEdge.condition', locale), value: conditionText(edge.condition) ?? '', onCommit: (v) => patchEdge({ condition: v || undefined }), placeholder: t('engine.inspector.flowEdge.conditionHint', locale), disabled: readOnly || isDefault, mono: true }), (() => {
118
+ patchEdge({ label: v }); }, disabled: readOnly })), _jsx(InspectorTextField, { label: t('engine.inspector.flowEdge.label', locale), value: edge.label ?? '', onCommit: (v) => patchEdge({ label: v }), placeholder: t('engine.inspector.flowEdge.labelHint', locale), disabled: readOnly || isDefault }), _jsxs("div", { className: "space-y-1", children: [_jsx(Label, { className: "text-xs text-muted-foreground", children: t('engine.inspector.flowEdge.condition', locale) }), _jsx(VariableTextInput, { mode: "expression", mono: true, value: conditionText(edge.condition) ?? '', onValueChange: (v) => patchEdge({ condition: v || undefined }), groups: scopeGroups, placeholder: t('engine.inspector.flowEdge.conditionHint', locale), disabled: readOnly || isDefault })] }), (() => {
113
119
  // ADR-0032 — flag a malformed edge guard (e.g. `{record.x}` brace-in-CEL)
114
120
  // inline, with the same corrective message as build/agent validation.
115
121
  const issue = isDefault ? null : validateExpressionClient('predicate', edge.condition);
116
- return issue ? (_jsx("p", { className: "text-[11px] leading-snug text-destructive", role: "alert", children: issue.message })) : null;
122
+ if (issue) {
123
+ return (_jsx("p", { className: "text-[11px] leading-snug text-destructive", role: "alert", children: issue.message }));
124
+ }
125
+ // #1934 — gentle scope-aware "unknown reference" warning (refs in scope
126
+ // at the edge's SOURCE node), once the guard is structurally valid.
127
+ const unknown = isDefault
128
+ ? []
129
+ : findUnknownRefs(conditionText(edge.condition), 'predicate', scopeRoots(scopeGroups.flatMap((g) => g.refs)));
130
+ return unknown.length > 0 ? (_jsx("p", { className: "text-[11px] leading-snug text-amber-600 dark:text-amber-400", role: "note", children: describeUnknownRefs(unknown) })) : null;
117
131
  })(), _jsx(InspectorCheckboxField, { label: t('engine.inspector.flowEdge.isDefault', locale), value: isDefault,
118
132
  // The default ("else") branch is taken when no other guard matches, so
119
133
  // it carries neither a condition nor a branch label — clear both.
@@ -0,0 +1,21 @@
1
+ /**
2
+ * FlowExprIssue — the shared inline validation line for an expression / template
3
+ * value in the flow inspector (#1934). Renders, in precedence order:
4
+ * 1. an ADR-0032 brace/shape ERROR (red) — CEL fields only; a genuine template
5
+ * uses single-brace `{var}` legally, so the brace check never runs there;
6
+ * 2. else a scope-aware "unknown reference" WARNING (amber) — a referenced
7
+ * root not in scope at the node, with a "did you mean?" hint.
8
+ * Returns null when the value is clean (or scope is unknown). Used by the picker
9
+ * repeater cells (decision Branches, screen visibleWhen, key/value values) that
10
+ * carry the picker but otherwise had no inline validation.
11
+ */
12
+ import * as React from 'react';
13
+ import { type ExprFieldRole } from './expression-validate';
14
+ import type { ScopeGroup } from './useFlowScope';
15
+ export interface FlowExprIssueProps {
16
+ value: unknown;
17
+ /** `'predicate'` / `'value'` → CEL (brace-checked); `'template'` → `{…}` holes. */
18
+ role: ExprFieldRole;
19
+ scopeGroups?: ScopeGroup[];
20
+ }
21
+ export declare function FlowExprIssue({ value, role, scopeGroups }: FlowExprIssueProps): React.ReactElement | null;
@@ -0,0 +1,13 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { validateExpressionClient } from './expression-validate';
3
+ import { findUnknownRefs, scopeRoots, describeUnknownRefs } from './flow-ref-check';
4
+ export function FlowExprIssue({ value, role, scopeGroups }) {
5
+ // Brace / shape error — CEL roles only (single-brace is valid in a template).
6
+ const issue = role === 'template' ? null : validateExpressionClient(role, value);
7
+ if (issue) {
8
+ return (_jsx("p", { className: "text-[11px] leading-snug text-destructive", role: "alert", children: issue.message }));
9
+ }
10
+ const roots = scopeGroups && scopeGroups.length > 0 ? scopeRoots(scopeGroups.flatMap((g) => g.refs)) : null;
11
+ const unknown = roots ? findUnknownRefs(value, role, roots) : [];
12
+ return unknown.length > 0 ? (_jsx("p", { className: "text-[11px] leading-snug text-amber-600 dark:text-amber-400", role: "note", children: describeUnknownRefs(unknown) })) : null;
13
+ }
@@ -15,10 +15,26 @@
15
15
  * rows take precedence).
16
16
  */
17
17
  import * as React from 'react';
18
+ import type { ScopeGroup } from './useFlowScope';
19
+ export interface Row {
20
+ id: string;
21
+ key: string;
22
+ /** Display string for the value cell. */
23
+ raw: string;
24
+ }
25
+ /**
26
+ * Read the stored value as `[key, value]` entries, accepting BOTH shapes a
27
+ * key/value config field can hold: the common object map (`{ var: value }`) and
28
+ * the assignment-node ARRAY form (`[{ variable|name|key, value }]`). The shape
29
+ * is preserved on write (see {@link rowsToValue}).
30
+ */
31
+ export declare function toEntries(value: unknown): Array<[string, unknown]>;
32
+ /** Flush rows back to the SAME shape, skipping empty/duplicate keys (first wins). */
33
+ export declare function rowsToValue(rows: Row[], arrayShape: boolean): Record<string, unknown> | Array<Record<string, unknown>>;
18
34
  export interface FlowKeyValueFieldProps {
19
35
  label: string;
20
36
  value: unknown;
21
- onCommit: (value: Record<string, unknown> | undefined) => void;
37
+ onCommit: (value: Record<string, unknown> | Array<Record<string, unknown>> | undefined) => void;
22
38
  disabled?: boolean;
23
39
  help?: string;
24
40
  addLabel: string;
@@ -26,5 +42,7 @@ export interface FlowKeyValueFieldProps {
26
42
  valueLabel: string;
27
43
  removeLabel: string;
28
44
  emptyLabel: string;
45
+ /** In-scope variable references for the data-picker (#1934). */
46
+ scopeGroups?: ScopeGroup[];
29
47
  }
30
- export declare function FlowKeyValueField({ label, value, onCommit, disabled, help, addLabel, keyLabel, valueLabel, removeLabel, emptyLabel, }: FlowKeyValueFieldProps): React.JSX.Element;
48
+ export declare function FlowKeyValueField({ label, value, onCommit, disabled, help, addLabel, keyLabel, valueLabel, removeLabel, emptyLabel, scopeGroups, }: FlowKeyValueFieldProps): React.JSX.Element;
@@ -20,6 +20,8 @@ import * as React from 'react';
20
20
  import { Plus, X } from 'lucide-react';
21
21
  import { Button, Input, Label } from '@object-ui/components';
22
22
  import { uniqueId } from './_shared';
23
+ import { VariableTextInput } from './VariableTextInput';
24
+ import { FlowExprIssue } from './FlowExprIssue';
23
25
  function isPlainObject(v) {
24
26
  return typeof v === 'object' && v !== null && !Array.isArray(v);
25
27
  }
@@ -58,49 +60,90 @@ function parseValue(raw) {
58
60
  }
59
61
  return raw;
60
62
  }
61
- function toRows(obj, existingIds) {
63
+ /**
64
+ * Read the stored value as `[key, value]` entries, accepting BOTH shapes a
65
+ * key/value config field can hold: the common object map (`{ var: value }`) and
66
+ * the assignment-node ARRAY form (`[{ variable|name|key, value }]`). The shape
67
+ * is preserved on write (see {@link rowsToValue}).
68
+ */
69
+ export function toEntries(value) {
70
+ if (Array.isArray(value)) {
71
+ return value
72
+ .filter((it) => isPlainObject(it))
73
+ .map((it) => {
74
+ const k = it.variable ?? it.name ?? it.key;
75
+ return [typeof k === 'string' ? k : '', it.value];
76
+ });
77
+ }
78
+ if (isPlainObject(value))
79
+ return Object.entries(value);
80
+ return [];
81
+ }
82
+ function toRows(value, existingIds) {
62
83
  const ids = [...existingIds];
63
- return Object.entries(obj).map(([key, value]) => {
84
+ return toEntries(value).map(([key, val]) => {
64
85
  const id = uniqueId('kv', ids);
65
86
  ids.push(id);
66
- return { id, key, raw: toRaw(value) };
87
+ return { id, key, raw: toRaw(val) };
67
88
  });
68
89
  }
69
- /** Flush rows to an object, skipping empty/duplicate keys (first wins). */
70
- function rowsToObject(rows) {
90
+ /** Flush rows back to the SAME shape, skipping empty/duplicate keys (first wins). */
91
+ export function rowsToValue(rows, arrayShape) {
92
+ const seen = new Set();
93
+ if (arrayShape) {
94
+ const list = [];
95
+ for (const r of rows) {
96
+ const k = r.key.trim();
97
+ if (!k || seen.has(k))
98
+ continue;
99
+ seen.add(k);
100
+ list.push({ variable: k, value: parseValue(r.raw) });
101
+ }
102
+ return list;
103
+ }
71
104
  const out = {};
72
105
  for (const r of rows) {
73
106
  const k = r.key.trim();
74
- if (!k || k in out)
107
+ if (!k || seen.has(k))
75
108
  continue;
109
+ seen.add(k);
76
110
  out[k] = parseValue(r.raw);
77
111
  }
78
112
  return out;
79
113
  }
80
- function serialize(obj) {
114
+ /** Stable serialization for the resync guard (order-insensitive for objects). */
115
+ function serialize(value) {
116
+ if (Array.isArray(value))
117
+ return JSON.stringify(value);
118
+ const obj = value ?? {};
81
119
  const sorted = Object.keys(obj).sort().reduce((acc, k) => {
82
120
  acc[k] = obj[k];
83
121
  return acc;
84
122
  }, {});
85
123
  return JSON.stringify(sorted);
86
124
  }
87
- export function FlowKeyValueField({ label, value, onCommit, disabled, help, addLabel, keyLabel, valueLabel, removeLabel, emptyLabel, }) {
88
- const external = isPlainObject(value) ? value : {};
89
- const [rows, setRows] = React.useState(() => toRows(external, []));
90
- // Track the last value we committed so an external change (node switch) can
91
- // resync rows without clobbering an in-progress edit of the same node.
92
- const lastCommitted = React.useRef(serialize(external));
125
+ export function FlowKeyValueField({ label, value, onCommit, disabled, help, addLabel, keyLabel, valueLabel, removeLabel, emptyLabel, scopeGroups, }) {
126
+ // Preserve whichever shape the value was authored in (object map vs the
127
+ // assignment-node array form) across edits.
128
+ const arrayShape = Array.isArray(value);
129
+ // Normalized serialization of the stored value used only to detect an
130
+ // EXTERNAL change (node switch) that should resync the rows.
131
+ const external = React.useMemo(() => serialize(rowsToValue(toRows(value, []), arrayShape)), [value, arrayShape]);
132
+ const [rows, setRows] = React.useState(() => toRows(value, []));
133
+ // Track the last value we committed so an external change can resync rows
134
+ // without clobbering an in-progress edit of the same node.
135
+ const lastCommitted = React.useRef(external);
93
136
  React.useEffect(() => {
94
- const next = serialize(external);
95
- if (next !== lastCommitted.current) {
96
- setRows(toRows(external, []));
97
- lastCommitted.current = next;
137
+ if (external !== lastCommitted.current) {
138
+ setRows(toRows(value, []));
139
+ lastCommitted.current = external;
98
140
  }
99
- }, [external]);
141
+ }, [external, value]);
100
142
  const flush = (nextRows) => {
101
- const obj = rowsToObject(nextRows);
102
- lastCommitted.current = serialize(obj);
103
- onCommit(Object.keys(obj).length ? obj : undefined);
143
+ const out = rowsToValue(nextRows, arrayShape);
144
+ lastCommitted.current = serialize(out);
145
+ const empty = Array.isArray(out) ? out.length === 0 : Object.keys(out).length === 0;
146
+ onCommit(empty ? undefined : out);
104
147
  };
105
148
  const setRowField = (id, patch) => {
106
149
  setRows((rs) => rs.map((r) => (r.id === id ? { ...r, ...patch } : r)));
@@ -115,11 +158,11 @@ export function FlowKeyValueField({ label, value, onCommit, disabled, help, addL
115
158
  return next;
116
159
  });
117
160
  };
118
- return (_jsxs("div", { className: "space-y-1.5", children: [_jsx(Label, { className: "text-xs text-muted-foreground", children: label }), _jsxs("div", { className: "space-y-1.5", children: [rows.length === 0 && (_jsx("p", { className: "text-[11px] italic text-muted-foreground", children: emptyLabel })), rows.map((row) => (_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx(Input, { value: row.key, onChange: (e) => setRowField(row.id, { key: e.target.value }), onBlur: () => flush(rows), onKeyDown: (e) => {
119
- if (e.key === 'Enter')
120
- e.target.blur();
121
- }, placeholder: keyLabel, disabled: disabled, className: "h-8 flex-1 font-mono text-xs" }), _jsx(Input, { value: row.raw, onChange: (e) => setRowField(row.id, { raw: e.target.value }), onBlur: () => flush(rows), onKeyDown: (e) => {
122
- if (e.key === 'Enter')
123
- e.target.blur();
124
- }, placeholder: valueLabel, disabled: disabled, className: "h-8 flex-1 text-xs" }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", className: "h-8 w-8 shrink-0 p-0 text-muted-foreground", onClick: () => removeRow(row.id), disabled: disabled, "aria-label": removeLabel, title: removeLabel, children: _jsx(X, { className: "h-3.5 w-3.5" }) })] }, row.id)))] }), _jsxs(Button, { type: "button", variant: "outline", size: "sm", className: "h-7 w-full text-xs", onClick: addRow, disabled: disabled, children: [_jsx(Plus, { className: "mr-1 h-3.5 w-3.5" }), addLabel] }), help && _jsx("p", { className: "text-[11px] leading-snug text-muted-foreground", children: help })] }));
161
+ return (_jsxs("div", { className: "space-y-1.5", children: [_jsx(Label, { className: "text-xs text-muted-foreground", children: label }), _jsxs("div", { className: "space-y-1.5", children: [rows.length === 0 && (_jsx("p", { className: "text-[11px] italic text-muted-foreground", children: emptyLabel })), rows.map((row) => (_jsxs("div", { className: "space-y-1", children: [_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx(Input, { value: row.key, onChange: (e) => setRowField(row.id, { key: e.target.value }), onBlur: () => flush(rows), onKeyDown: (e) => {
162
+ if (e.key === 'Enter')
163
+ e.target.blur();
164
+ }, placeholder: keyLabel, disabled: disabled, className: "h-8 flex-1 font-mono text-xs" }), _jsx(VariableTextInput, { mode: "template", value: row.raw, onValueChange: (v) => setRowField(row.id, { raw: v }), onBlur: () => flush(rows), onKeyDown: (e) => {
165
+ if (e.key === 'Enter')
166
+ e.target.blur();
167
+ }, groups: scopeGroups ?? [], placeholder: valueLabel, disabled: disabled, className: "flex-1" }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", className: "h-8 w-8 shrink-0 p-0 text-muted-foreground", onClick: () => removeRow(row.id), disabled: disabled, "aria-label": removeLabel, title: removeLabel, children: _jsx(X, { className: "h-3.5 w-3.5" }) })] }), _jsx(FlowExprIssue, { value: row.raw, role: "template", scopeGroups: scopeGroups })] }, row.id)))] }), _jsxs(Button, { type: "button", variant: "outline", size: "sm", className: "h-7 w-full text-xs", onClick: addRow, disabled: disabled, children: [_jsx(Plus, { className: "mr-1 h-3.5 w-3.5" }), addLabel] }), help && _jsx("p", { className: "text-[11px] leading-snug text-muted-foreground", children: help })] }));
125
168
  }
@@ -6,6 +6,7 @@
6
6
  import * as React from 'react';
7
7
  import type { FlowConfigField } from './flow-node-config';
8
8
  import { type FlowReferenceContext } from './FlowReferenceField';
9
+ import type { ScopeGroup } from './useFlowScope';
9
10
  export interface FlowNodeConfigFieldProps {
10
11
  field: FlowConfigField;
11
12
  value: unknown;
@@ -14,5 +15,7 @@ export interface FlowNodeConfigFieldProps {
14
15
  locale?: string;
15
16
  /** Draft + node context so `reference` fields can resolve their options. */
16
17
  context?: FlowReferenceContext;
18
+ /** In-scope variable references for the data-picker (#1934). */
19
+ scopeGroups?: ScopeGroup[];
17
20
  }
18
- export declare function FlowNodeConfigField({ field, value, onCommit, disabled, locale, context }: FlowNodeConfigFieldProps): React.JSX.Element;
21
+ export declare function FlowNodeConfigField({ field, value, onCommit, disabled, locale, context, scopeGroups }: FlowNodeConfigFieldProps): React.JSX.Element;
@@ -1,23 +1,26 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { t } from '../i18n';
3
- import { InspectorTextField, InspectorNumberField, InspectorSelectField, InspectorCheckboxField, } from './_shared';
3
+ import { InspectorNumberField, InspectorSelectField, InspectorCheckboxField, } from './_shared';
4
4
  import { Label } from '@object-ui/components';
5
5
  import { FlowKeyValueField } from './FlowKeyValueField';
6
6
  import { FlowStringListField } from './FlowStringListField';
7
7
  import { FlowObjectListField } from './FlowObjectListField';
8
8
  import { FlowReferenceField } from './FlowReferenceField';
9
9
  import { validateExpressionClient } from './expression-validate';
10
- export function FlowNodeConfigField({ field, value, onCommit, disabled, locale, context }) {
10
+ import { VariableTextInput } from './VariableTextInput';
11
+ import { findUnknownRefs, scopeRoots, describeUnknownRefs } from './flow-ref-check';
12
+ export function FlowNodeConfigField({ field, value, onCommit, disabled, locale, context, scopeGroups }) {
13
+ const refMode = field.refMode ?? (field.kind === 'expression' ? 'expression' : 'template');
11
14
  const control = (() => {
12
15
  switch (field.kind) {
13
16
  case 'reference':
14
17
  return (_jsx(FlowReferenceField, { field: field, value: value, onCommit: (v) => onCommit(v), disabled: disabled, context: context }));
15
18
  case 'keyValue':
16
- return (_jsx(FlowKeyValueField, { label: field.label, value: value, onCommit: (v) => onCommit(v), disabled: disabled, addLabel: t('engine.inspector.flowNode.kv.add', locale), keyLabel: t('engine.inspector.flowNode.kv.key', locale), valueLabel: t('engine.inspector.flowNode.kv.value', locale), removeLabel: t('engine.inspector.flowNode.kv.remove', locale), emptyLabel: t('engine.inspector.flowNode.kv.empty', locale) }));
19
+ return (_jsx(FlowKeyValueField, { label: field.label, value: value, onCommit: (v) => onCommit(v), disabled: disabled, addLabel: t('engine.inspector.flowNode.kv.add', locale), keyLabel: t('engine.inspector.flowNode.kv.key', locale), valueLabel: t('engine.inspector.flowNode.kv.value', locale), removeLabel: t('engine.inspector.flowNode.kv.remove', locale), emptyLabel: t('engine.inspector.flowNode.kv.empty', locale), scopeGroups: scopeGroups }));
17
20
  case 'stringList':
18
21
  return (_jsx(FlowStringListField, { label: field.label, value: value, onCommit: (v) => onCommit(v), disabled: disabled, addLabel: t('engine.inspector.flowNode.list.add', locale), itemLabel: t('engine.inspector.flowNode.list.item', locale), removeLabel: t('engine.inspector.flowNode.list.remove', locale), emptyLabel: t('engine.inspector.flowNode.list.empty', locale) }));
19
22
  case 'objectList':
20
- return (_jsx(FlowObjectListField, { label: field.label, columns: field.columns ?? [], value: value, onCommit: (v) => onCommit(v), disabled: disabled, addLabel: t('engine.inspector.flowNode.list.add', locale), removeLabel: t('engine.inspector.flowNode.list.remove', locale), emptyLabel: t('engine.inspector.flowNode.list.empty', locale), context: context }));
23
+ return (_jsx(FlowObjectListField, { label: field.label, columns: field.columns ?? [], value: value, onCommit: (v) => onCommit(v), disabled: disabled, addLabel: t('engine.inspector.flowNode.list.add', locale), removeLabel: t('engine.inspector.flowNode.list.remove', locale), emptyLabel: t('engine.inspector.flowNode.list.empty', locale), context: context, scopeGroups: scopeGroups }));
21
24
  case 'number':
22
25
  return (_jsx(InspectorNumberField, { label: field.label, value: typeof value === 'number' ? value : value != null && value !== '' ? Number(value) : undefined, placeholder: field.placeholder, onCommit: (v) => onCommit(v), disabled: disabled }));
23
26
  case 'boolean':
@@ -25,16 +28,28 @@ export function FlowNodeConfigField({ field, value, onCommit, disabled, locale,
25
28
  case 'select':
26
29
  return (_jsx(InspectorSelectField, { label: field.label, value: value != null ? String(value) : '', options: field.options ?? [], onCommit: (v) => onCommit(v), disabled: disabled }));
27
30
  case 'textarea':
28
- return (_jsxs("div", { className: "space-y-1", children: [_jsx(Label, { className: "text-xs text-muted-foreground", children: field.label }), _jsx("textarea", { value: value != null ? String(value) : '', onChange: (e) => onCommit(e.target.value), placeholder: field.placeholder, disabled: disabled, rows: 4, className: "w-full rounded border bg-background px-2 py-1.5 font-mono text-xs" })] }));
31
+ return (_jsxs("div", { className: "space-y-1", children: [_jsx(Label, { className: "text-xs text-muted-foreground", children: field.label }), _jsx(VariableTextInput, { multiline: true, rows: 4, mode: refMode, value: value != null ? String(value) : '', onValueChange: (v) => onCommit(v), groups: scopeGroups ?? [], placeholder: field.placeholder, disabled: disabled })] }));
29
32
  case 'expression':
30
33
  case 'text':
31
34
  default:
32
- return (_jsx(InspectorTextField, { label: field.label, value: value != null ? String(value) : '', placeholder: field.placeholder, onCommit: (v) => onCommit(v), disabled: disabled, mono: field.kind === 'expression' }));
35
+ return (_jsxs("div", { className: "space-y-1", children: [_jsx(Label, { className: "text-xs text-muted-foreground", children: field.label }), _jsx(VariableTextInput, { mode: refMode, mono: field.kind === 'expression', value: value != null ? String(value) : '', onValueChange: (v) => onCommit(v), groups: scopeGroups ?? [], placeholder: field.placeholder, disabled: disabled })] }));
33
36
  }
34
37
  })();
35
38
  // ADR-0032 — surface a malformed condition (e.g. the `{record.x}` brace-in-CEL
36
- // mistake) inline, as the author types, with the same corrective message the
37
- // build and the agent tool emit. Only checked for expression-bearing fields.
39
+ // mistake) inline, with the same corrective message the build/agent emit. Only
40
+ // for expression fields (a genuine template uses single-brace `{var}` legally).
38
41
  const exprIssue = field.kind === 'expression' ? validateExpressionClient('predicate', value) : null;
39
- return (_jsxs("div", { className: "space-y-1", children: [control, exprIssue && (_jsx("p", { className: "text-[11px] leading-snug text-destructive", role: "alert", children: exprIssue.message })), field.help && !exprIssue && (_jsx("p", { className: "text-[11px] leading-snug text-muted-foreground", children: field.help }))] }));
42
+ // #1934 pair the picker with a gentle, scope-aware "unknown reference"
43
+ // warning: CEL for expression fields, `{…}` holes for template fields. Skipped
44
+ // for free-form code (refMode 'expression' on a textarea, e.g. a script body)
45
+ // and when scope is unknown. The brace error above takes precedence.
46
+ const scopeRole = field.kind === 'expression'
47
+ ? 'predicate'
48
+ : refMode === 'template' && (field.kind === 'text' || field.kind === 'textarea')
49
+ ? 'template'
50
+ : null;
51
+ const unknownRefs = !exprIssue && scopeRole && scopeGroups && scopeGroups.length > 0
52
+ ? findUnknownRefs(value, scopeRole, scopeRoots(scopeGroups.flatMap((g) => g.refs)))
53
+ : [];
54
+ return (_jsxs("div", { className: "space-y-1", children: [control, exprIssue && (_jsx("p", { className: "text-[11px] leading-snug text-destructive", role: "alert", children: exprIssue.message })), !exprIssue && unknownRefs.length > 0 && (_jsx("p", { className: "text-[11px] leading-snug text-amber-600 dark:text-amber-400", role: "note", children: describeUnknownRefs(unknownRefs) })), field.help && !exprIssue && unknownRefs.length === 0 && (_jsx("p", { className: "text-[11px] leading-snug text-muted-foreground", children: field.help }))] }));
40
55
  }
@@ -19,6 +19,7 @@ import { fieldsForNodeType, isFieldVisible, getFieldValue, configKeyOf, FLOW_NOD
19
19
  import { jsonSchemaToFlowFields } from './json-schema-to-fields';
20
20
  import { useActionConfigSchemas } from '../previews/useFlowNodePalette';
21
21
  import { FlowNodeConfigField } from './FlowNodeConfigField';
22
+ import { useFlowScope } from './useFlowScope';
22
23
  import { ScreenPreview } from '../previews/ScreenPreview';
23
24
  /**
24
25
  * Mirror a decision node's `conditions` (branches) onto its outgoing sequence
@@ -101,6 +102,8 @@ export function FlowNodeInspector({ selection, draft, onPatch, onClearSelection,
101
102
  // with the backend. Falls back to the hardcoded field group when no schema is
102
103
  // published (offline / plugin absent / older backend).
103
104
  const configSchemas = useActionConfigSchemas();
105
+ // In-scope variable references for this node, for the data-picker (#1934).
106
+ const { groups: scopeGroups } = useFlowScope(draft, node?.id);
104
107
  const fields = React.useMemo(() => {
105
108
  const schema = node?.type ? configSchemas[node.type] : undefined;
106
109
  const serverFields = schema !== undefined ? jsonSchemaToFlowFields(schema) : null;
@@ -127,6 +130,10 @@ export function FlowNodeInspector({ selection, draft, onPatch, onClearSelection,
127
130
  const k = configKeyOf(f);
128
131
  if (k)
129
132
  s.add(k);
133
+ // A loose-shape fallback rooted at `config` is claimed too, so a tolerated
134
+ // legacy key (e.g. a wait node's `config.eventType`) never leaks to Advanced.
135
+ if (f.fallbackPath && f.fallbackPath.length >= 2 && f.fallbackPath[0] === 'config')
136
+ s.add(f.fallbackPath[1]);
130
137
  }
131
138
  return s;
132
139
  }, [fields]);
@@ -155,8 +162,13 @@ export function FlowNodeInspector({ selection, draft, onPatch, onClearSelection,
155
162
  const hasExtras = extraJson.trim() !== '';
156
163
  // Screen nodes (and the `user_task` alias) get a live end-user preview.
157
164
  const isScreen = node.type === 'screen' || node.type === 'user_task';
158
- const setField = (path, value) => {
159
- const nextNode = setAtPath(node, path, value);
165
+ const setField = (field, value) => {
166
+ const path = field.path;
167
+ let nextNode = setAtPath(node, path, value);
168
+ // Migrate-on-edit: writing the canonical path drops any looser fallback
169
+ // location, so the node never carries a stale duplicate (engine + designer agree).
170
+ if (field.fallbackPath)
171
+ nextNode = setAtPath(nextNode, field.fallbackPath, undefined);
160
172
  const patch = { nodes: spliceArray(nodes, index, nextNode) };
161
173
  // Decision branches drive routing — mirror them onto the node's outgoing
162
174
  // edges so the engine/simulator can actually branch (they read
@@ -198,7 +210,7 @@ export function FlowNodeInspector({ selection, draft, onPatch, onClearSelection,
198
210
  const typeOptions = FLOW_NODE_TYPE_OPTIONS.includes(node.type)
199
211
  ? [...FLOW_NODE_TYPE_OPTIONS]
200
212
  : [...FLOW_NODE_TYPE_OPTIONS, node.type ?? ''].filter(Boolean);
201
- return (_jsxs(InspectorShell, { kindLabel: t('engine.inspector.flowNode.kind', locale), title: node.label || node.id, onClose: onClearSelection, closeLabel: t('engine.inspector.flowNode.close', locale), footer: _jsx(InspectorRemoveButton, { label: t('engine.inspector.flowNode.remove', locale), onClick: remove, disabled: readOnly }), children: [_jsx(InspectorTextField, { label: t('engine.inspector.flowNode.id', locale), value: node.id, onCommit: (v) => patchNode({ id: v }), disabled: readOnly, mono: true }), _jsx(InspectorTextField, { label: t('engine.inspector.flowNode.label', locale), value: node.label ?? '', onCommit: (v) => patchNode({ label: v }), disabled: readOnly }), _jsx(InspectorSelectField, { label: t('engine.inspector.flowNode.type', locale), value: node.type, options: typeOptions.map((v) => ({ value: v, label: v })), onCommit: (v) => patchNode({ type: v }), disabled: readOnly }), _jsx(InspectorTextField, { label: t('engine.inspector.flowNode.description', locale), value: node.description ?? '', onCommit: (v) => patchNode({ description: v || undefined }), disabled: readOnly }), fields.length === 0 ? (_jsx("p", { className: "pt-1 text-xs italic text-muted-foreground", children: t('engine.inspector.flowNode.noConfig', locale) })) : (visibleFields.length > 0 && (_jsxs("div", { className: "flex items-center gap-2 pt-1", children: [_jsx("span", { className: "text-[11px] font-semibold uppercase tracking-wide text-muted-foreground", children: t('engine.inspector.flowNode.configuration', locale) }), _jsx("span", { className: "h-px flex-1 bg-border", "aria-hidden": true })] }))), visibleFields.map((field) => (_jsx(FlowNodeConfigField, { field: field, value: getFieldValue(node, field), onCommit: (v) => setField(field.path, v), disabled: readOnly, locale: locale, context: { draft, node } }, field.id))), isScreen && _jsx(ScreenPreview, { node: node, variables: screenVars, className: "mt-1" }), hasExtras || advReveal ? (_jsxs("details", { className: "group rounded border bg-muted/20", open: advOpen, onToggle: (e) => setAdvOpen(e.target.open), children: [_jsx("summary", { className: "cursor-pointer select-none px-2 py-1.5 text-xs font-medium text-muted-foreground", children: t('engine.inspector.flowNode.advanced', locale) }), _jsxs("div", { className: "space-y-1 border-t p-2", children: [_jsx("p", { className: "text-[11px] leading-snug text-muted-foreground", children: t('engine.inspector.flowNode.advancedHint', locale) }), _jsx("textarea", { value: advText, onChange: (e) => setAdvText(e.target.value), onBlur: commitAdvanced, disabled: readOnly, rows: 6, placeholder: "{ }", className: "w-full rounded border bg-background px-2 py-1.5 font-mono text-xs" }), advError && _jsx("div", { className: "text-xs text-destructive", children: advError })] })] })) : (!readOnly && (_jsxs("button", { type: "button", onClick: () => {
213
+ return (_jsxs(InspectorShell, { kindLabel: t('engine.inspector.flowNode.kind', locale), title: node.label || node.id, onClose: onClearSelection, closeLabel: t('engine.inspector.flowNode.close', locale), footer: _jsx(InspectorRemoveButton, { label: t('engine.inspector.flowNode.remove', locale), onClick: remove, disabled: readOnly }), children: [_jsx(InspectorTextField, { label: t('engine.inspector.flowNode.id', locale), value: node.id, onCommit: (v) => patchNode({ id: v }), disabled: readOnly, mono: true }), _jsx(InspectorTextField, { label: t('engine.inspector.flowNode.label', locale), value: node.label ?? '', onCommit: (v) => patchNode({ label: v }), disabled: readOnly }), _jsx(InspectorSelectField, { label: t('engine.inspector.flowNode.type', locale), value: node.type, options: typeOptions.map((v) => ({ value: v, label: v })), onCommit: (v) => patchNode({ type: v }), disabled: readOnly }), _jsx(InspectorTextField, { label: t('engine.inspector.flowNode.description', locale), value: node.description ?? '', onCommit: (v) => patchNode({ description: v || undefined }), disabled: readOnly }), fields.length === 0 ? (_jsx("p", { className: "pt-1 text-xs italic text-muted-foreground", children: t('engine.inspector.flowNode.noConfig', locale) })) : (visibleFields.length > 0 && (_jsxs("div", { className: "flex items-center gap-2 pt-1", children: [_jsx("span", { className: "text-[11px] font-semibold uppercase tracking-wide text-muted-foreground", children: t('engine.inspector.flowNode.configuration', locale) }), _jsx("span", { className: "h-px flex-1 bg-border", "aria-hidden": true })] }))), visibleFields.map((field) => (_jsx(FlowNodeConfigField, { field: field, value: getFieldValue(node, field), onCommit: (v) => setField(field, v), disabled: readOnly, locale: locale, context: { draft, node }, scopeGroups: scopeGroups }, field.id))), isScreen && _jsx(ScreenPreview, { node: node, variables: screenVars, className: "mt-1" }), hasExtras || advReveal ? (_jsxs("details", { className: "group rounded border bg-muted/20", open: advOpen, onToggle: (e) => setAdvOpen(e.target.open), children: [_jsx("summary", { className: "cursor-pointer select-none px-2 py-1.5 text-xs font-medium text-muted-foreground", children: t('engine.inspector.flowNode.advanced', locale) }), _jsxs("div", { className: "space-y-1 border-t p-2", children: [_jsx("p", { className: "text-[11px] leading-snug text-muted-foreground", children: t('engine.inspector.flowNode.advancedHint', locale) }), _jsx("textarea", { value: advText, onChange: (e) => setAdvText(e.target.value), onBlur: commitAdvanced, disabled: readOnly, rows: 6, placeholder: "{ }", className: "w-full rounded border bg-background px-2 py-1.5 font-mono text-xs" }), advError && _jsx("div", { className: "text-xs text-destructive", children: advError })] })] })) : (!readOnly && (_jsxs("button", { type: "button", onClick: () => {
202
214
  setAdvReveal(true);
203
215
  setAdvOpen(true);
204
216
  }, className: "inline-flex items-center gap-1 self-start text-[11px] text-muted-foreground transition-colors hover:text-foreground", children: [_jsx(Plus, { className: "h-3 w-3" }), t('engine.inspector.flowNode.advanced', locale)] })))] }));
@@ -11,6 +11,7 @@
11
11
  import * as React from 'react';
12
12
  import type { FlowConfigColumn } from './flow-node-config';
13
13
  import { type FlowReferenceContext } from './FlowReferenceField';
14
+ import type { ScopeGroup } from './useFlowScope';
14
15
  export interface FlowObjectListFieldProps {
15
16
  label: string;
16
17
  columns: FlowConfigColumn[];
@@ -22,5 +23,7 @@ export interface FlowObjectListFieldProps {
22
23
  emptyLabel: string;
23
24
  /** Draft + node context so `reference` columns can resolve their options. */
24
25
  context?: FlowReferenceContext;
26
+ /** In-scope variable references for `expression` columns (#1934). */
27
+ scopeGroups?: ScopeGroup[];
25
28
  }
26
- export declare function FlowObjectListField({ label, columns, value, onCommit, disabled, addLabel, removeLabel, emptyLabel, context, }: FlowObjectListFieldProps): React.JSX.Element;
29
+ export declare function FlowObjectListField({ label, columns, value, onCommit, disabled, addLabel, removeLabel, emptyLabel, context, scopeGroups, }: FlowObjectListFieldProps): React.JSX.Element;
@@ -15,6 +15,8 @@ import { Plus, X } from 'lucide-react';
15
15
  import { Button, Input, Label, Checkbox } from '@object-ui/components';
16
16
  import { uniqueId } from './_shared';
17
17
  import { ReferenceCombobox, resolveRefKind } from './FlowReferenceField';
18
+ import { VariableTextInput } from './VariableTextInput';
19
+ import { FlowExprIssue } from './FlowExprIssue';
18
20
  function toRows(list, columns) {
19
21
  const ids = [];
20
22
  return list.map((item) => {
@@ -56,7 +58,7 @@ function rowsToList(rows, columns) {
56
58
  }
57
59
  return out;
58
60
  }
59
- export function FlowObjectListField({ label, columns, value, onCommit, disabled, addLabel, removeLabel, emptyLabel, context, }) {
61
+ export function FlowObjectListField({ label, columns, value, onCommit, disabled, addLabel, removeLabel, emptyLabel, context, scopeGroups, }) {
60
62
  const external = React.useMemo(() => Array.isArray(value)
61
63
  ? value.filter((v) => v && typeof v === 'object')
62
64
  : [], [value]);
@@ -98,8 +100,11 @@ export function FlowObjectListField({ label, columns, value, onCommit, disabled,
98
100
  flush(next);
99
101
  return next;
100
102
  });
101
- }, disabled: disabled })) : col.kind === 'reference' ? (_jsx("div", { className: "flex-1", children: _jsx(ReferenceCombobox, { resolved: resolveRefKind(col.ref, (k) => row.values[k]), value: typeof row.values[col.key] === 'string' ? row.values[col.key] : '', onCommit: (v) => setCell(row.id, col.key, typeof v === 'string' ? v : ''), onBlur: () => flush(rows), placeholder: col.placeholder, disabled: disabled, context: context, showHint: false }) })) : (_jsx(Input, { value: typeof row.values[col.key] === 'string' ? row.values[col.key] : '', onChange: (e) => setCell(row.id, col.key, e.target.value), onBlur: () => flush(rows), onKeyDown: (e) => {
103
+ }, disabled: disabled })) : col.kind === 'reference' ? (_jsx("div", { className: "flex-1", children: _jsx(ReferenceCombobox, { resolved: resolveRefKind(col.ref, (k) => row.values[k]), value: typeof row.values[col.key] === 'string' ? row.values[col.key] : '', onCommit: (v) => setCell(row.id, col.key, typeof v === 'string' ? v : ''), onBlur: () => flush(rows), placeholder: col.placeholder, disabled: disabled, context: context, showHint: false }) })) : col.kind === 'expression' ? (_jsxs("div", { className: "flex-1 space-y-1", children: [_jsx(VariableTextInput, { mode: "expression", mono: true, value: typeof row.values[col.key] === 'string' ? row.values[col.key] : '', onValueChange: (v) => setCell(row.id, col.key, v), onBlur: () => flush(rows), onKeyDown: (e) => {
104
+ if (e.key === 'Enter')
105
+ e.target.blur();
106
+ }, groups: scopeGroups ?? [], placeholder: col.placeholder, disabled: disabled }), _jsx(FlowExprIssue, { value: typeof row.values[col.key] === 'string' ? row.values[col.key] : '', role: "predicate", scopeGroups: scopeGroups })] })) : (_jsx(Input, { value: typeof row.values[col.key] === 'string' ? row.values[col.key] : '', onChange: (e) => setCell(row.id, col.key, e.target.value), onBlur: () => flush(rows), onKeyDown: (e) => {
102
107
  if (e.key === 'Enter')
103
108
  e.target.blur();
104
- }, placeholder: col.placeholder, disabled: disabled, className: `h-8 flex-1 text-xs${col.kind === 'expression' ? ' font-mono' : ''}` }))] }, col.key))) })] }, row.id)))] }), _jsxs(Button, { type: "button", variant: "outline", size: "sm", className: "h-7 w-full text-xs", onClick: addRow, disabled: disabled, children: [_jsx(Plus, { className: "mr-1 h-3.5 w-3.5" }), addLabel] })] }));
109
+ }, placeholder: col.placeholder, disabled: disabled, className: "h-8 flex-1 text-xs" }))] }, col.key))) })] }, row.id)))] }), _jsxs(Button, { type: "button", variant: "outline", size: "sm", className: "h-7 w-full text-xs", onClick: addRow, disabled: disabled, children: [_jsx(Plus, { className: "mr-1 h-3.5 w-3.5" }), addLabel] })] }));
105
110
  }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * VariableTextInput — a single-line input (or textarea) for an expression /
3
+ * template flow-config value, with a "{x}" data-picker (#1934) that inserts an
4
+ * in-scope reference at the cursor.
5
+ *
6
+ * Brace handling is done FOR the author (ADR-0032): the picker inserts the BARE
7
+ * reference (`discount_pct`) in `expression` fields and the braced
8
+ * `{discount_pct}` in `template` (text / textarea) fields — killing the
9
+ * recurring `{record.x}` brace-in-CEL trap. Free-text typing is untouched, and
10
+ * the picker button is hidden entirely when nothing is in scope, so an empty
11
+ * scope degrades to a plain input.
12
+ */
13
+ import * as React from 'react';
14
+ import type { ScopeGroup } from './useFlowScope';
15
+ export type VariableFieldMode = 'expression' | 'template';
16
+ /** Wrap a bare reference token for insertion into the given field mode. */
17
+ export declare function formatToken(token: string, mode: VariableFieldMode): string;
18
+ /**
19
+ * Splice a reference into `value` at the selection `[selStart, selEnd]`, in the
20
+ * brace mode for the field, returning the new string and the caret position
21
+ * just after the inserted token. Pure (the DOM caret restore lives in the
22
+ * component); selection bounds are clamped and order-normalized so a reversed or
23
+ * out-of-range selection can't corrupt the value.
24
+ */
25
+ export declare function insertToken(value: string, mode: VariableFieldMode, token: string, selStart: number, selEnd: number): {
26
+ next: string;
27
+ caret: number;
28
+ };
29
+ export interface VariableTextInputProps {
30
+ value: string;
31
+ onValueChange: (v: string) => void;
32
+ onBlur?: () => void;
33
+ onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
34
+ /** Brace rule: bare token for `expression`, `{token}` for `template`. */
35
+ mode: VariableFieldMode;
36
+ /** In-scope reference groups (from useFlowScope). Empty → no picker button. */
37
+ groups: ScopeGroup[];
38
+ placeholder?: string;
39
+ disabled?: boolean;
40
+ /** Render a multi-line `<textarea>` instead of a single-line input. */
41
+ multiline?: boolean;
42
+ rows?: number;
43
+ /** Monospace the input text (expressions). Textareas are always mono. */
44
+ mono?: boolean;
45
+ className?: string;
46
+ }
47
+ export declare function VariableTextInput({ value, onValueChange, onBlur, onKeyDown, mode, groups, placeholder, disabled, multiline, rows, mono, className, }: VariableTextInputProps): React.JSX.Element;