@object-ui/app-shell 7.0.0 → 7.1.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 (73) hide show
  1. package/CHANGELOG.md +281 -0
  2. package/dist/console/AppContent.js +14 -2
  3. package/dist/console/ai/AiChatPage.js +11 -7
  4. package/dist/console/ai/LiveCanvas.d.ts +8 -2
  5. package/dist/console/ai/LiveCanvas.js +6 -4
  6. package/dist/hooks/useChatConversation.d.ts +30 -0
  7. package/dist/hooks/useChatConversation.js +63 -0
  8. package/dist/hooks/useConsoleActionRuntime.js +6 -2
  9. package/dist/index.d.ts +2 -1
  10. package/dist/index.js +5 -1
  11. package/dist/layout/ConsoleFloatingChatbot.d.ts +6 -4
  12. package/dist/layout/ConsoleFloatingChatbot.js +25 -8
  13. package/dist/layout/ContextSelectors.js +59 -35
  14. package/dist/layout/agentPicker.d.ts +56 -0
  15. package/dist/layout/agentPicker.js +40 -0
  16. package/dist/preview/CommitTimeline.d.ts +15 -0
  17. package/dist/preview/CommitTimeline.js +82 -0
  18. package/dist/preview/UnpublishedAppBar.js +11 -7
  19. package/dist/preview/commitHistory.d.ts +28 -0
  20. package/dist/preview/commitHistory.js +48 -0
  21. package/dist/providers/MetadataProvider.js +9 -0
  22. package/dist/views/FlowRunner.d.ts +2 -30
  23. package/dist/views/FlowRunner.js +18 -50
  24. package/dist/views/ScreenView.d.ts +70 -0
  25. package/dist/views/ScreenView.js +73 -0
  26. package/dist/views/metadata-admin/DirectoryPage.js +2 -14
  27. package/dist/views/metadata-admin/JsonSourceEditor.d.ts +3 -1
  28. package/dist/views/metadata-admin/JsonSourceEditor.js +21 -3
  29. package/dist/views/metadata-admin/PackagesPage.js +9 -1
  30. package/dist/views/metadata-admin/ResourceEditPage.js +47 -20
  31. package/dist/views/metadata-admin/ResourceListPage.js +8 -16
  32. package/dist/views/metadata-admin/StudioHomePage.js +6 -14
  33. package/dist/views/metadata-admin/anchors.js +20 -2
  34. package/dist/views/metadata-admin/i18n.js +88 -2
  35. package/dist/views/metadata-admin/inspectors/DatasetDefaultInspector.d.ts +2 -2
  36. package/dist/views/metadata-admin/inspectors/DatasetDefaultInspector.js +122 -8
  37. package/dist/views/metadata-admin/inspectors/FlowEdgeInspector.js +84 -3
  38. package/dist/views/metadata-admin/inspectors/FlowNodeInspector.js +67 -2
  39. package/dist/views/metadata-admin/inspectors/ObjectDefaultInspector.js +5 -4
  40. package/dist/views/metadata-admin/inspectors/ObjectFieldInspector.js +47 -12
  41. package/dist/views/metadata-admin/inspectors/ReportDefaultInspector.d.ts +1 -1
  42. package/dist/views/metadata-admin/inspectors/ReportDefaultInspector.js +60 -2
  43. package/dist/views/metadata-admin/inspectors/_shared.d.ts +5 -1
  44. package/dist/views/metadata-admin/inspectors/_shared.js +2 -2
  45. package/dist/views/metadata-admin/inspectors/datasetFilterCondition.d.ts +24 -0
  46. package/dist/views/metadata-admin/inspectors/datasetFilterCondition.js +97 -0
  47. package/dist/views/metadata-admin/inspectors/flow-node-config.js +46 -1
  48. package/dist/views/metadata-admin/issuePath.d.ts +22 -0
  49. package/dist/views/metadata-admin/issuePath.js +65 -0
  50. package/dist/views/metadata-admin/package-scope.d.ts +26 -0
  51. package/dist/views/metadata-admin/package-scope.js +43 -0
  52. package/dist/views/metadata-admin/previews/DatasetPreview.js +21 -5
  53. package/dist/views/metadata-admin/previews/FlowCanvas.d.ts +7 -1
  54. package/dist/views/metadata-admin/previews/FlowCanvas.js +104 -16
  55. package/dist/views/metadata-admin/previews/FlowPreview.js +31 -3
  56. package/dist/views/metadata-admin/previews/FlowSimulatorPanel.js +37 -3
  57. package/dist/views/metadata-admin/previews/PagePreview.js +112 -3
  58. package/dist/views/metadata-admin/previews/ScreenPreview.d.ts +38 -0
  59. package/dist/views/metadata-admin/previews/ScreenPreview.js +61 -0
  60. package/dist/views/metadata-admin/previews/flow-canvas-layout.d.ts +14 -0
  61. package/dist/views/metadata-admin/previews/flow-canvas-layout.js +37 -0
  62. package/dist/views/metadata-admin/previews/flow-canvas-parts.d.ts +9 -1
  63. package/dist/views/metadata-admin/previews/flow-canvas-parts.js +21 -6
  64. package/dist/views/metadata-admin/previews/object-fields-io.d.ts +21 -0
  65. package/dist/views/metadata-admin/previews/object-fields-io.js +37 -2
  66. package/dist/views/metadata-admin/previews/screen-spec.d.ts +43 -0
  67. package/dist/views/metadata-admin/previews/screen-spec.js +108 -0
  68. package/dist/views/metadata-admin/previews/simulator/flow-sim-types.d.ts +11 -0
  69. package/dist/views/metadata-admin/previews/simulator/flow-sim-validate.d.ts +7 -0
  70. package/dist/views/metadata-admin/previews/simulator/flow-sim-validate.js +72 -0
  71. package/dist/views/metadata-admin/previews/simulator/flow-simulator.d.ts +32 -3
  72. package/dist/views/metadata-admin/previews/simulator/flow-simulator.js +119 -9
  73. package/package.json +38 -38
@@ -40,10 +40,12 @@ const FLOW_NODE_CONFIG = {
40
40
  ref: { kind: 'object' },
41
41
  placeholder: 'crm_lead',
42
42
  help: 'Target object for record / scheduled-scan triggers.',
43
+ showWhen: { field: 'triggerType', equals: ['record-after-create', 'record-after-update', 'record-before-update', 'record-after-delete', 'record-change', 'schedule', 'webhook', 'event'] },
43
44
  }),
44
45
  cfg('condition', 'Entry condition', 'expression', {
45
46
  placeholder: 'status == "qualifying" && previous.status != "qualifying"',
46
47
  help: 'CEL predicate — the flow runs only when this is true. Leave empty to run on every event.',
48
+ showWhen: { field: 'triggerType', equals: ['record-after-create', 'record-after-update', 'record-before-update', 'record-after-delete', 'record-change', 'schedule', 'webhook', 'event'] },
47
49
  }),
48
50
  cfg('cron', 'Cron schedule', 'text', {
49
51
  placeholder: '0 7 * * *',
@@ -169,9 +171,19 @@ const FLOW_NODE_CONFIG = {
169
171
  }),
170
172
  { id: 'timeoutMs', path: ['timeoutMs'], label: 'Timeout (ms)', kind: 'number', placeholder: '30000' },
171
173
  ],
174
+ // Screen — collect input (a flat `fields` list) OR render an object's full
175
+ // create/edit form (`objectName`, master-detail). `title`/`description`
176
+ // head the screen (description interpolates {var}); `waitForInput` forces a
177
+ // pause on a field-less message/confirmation screen. All optional and shown
178
+ // together so neither a message screen nor an object-form step needs JSON.
172
179
  screen: [
180
+ cfg('title', 'Title', 'text', { placeholder: 'Request a discount', help: 'Heading shown above the screen.' }),
181
+ cfg('description', 'Description', 'textarea', {
182
+ placeholder: 'Enter the deal amount and the discount you want.',
183
+ help: 'Body text. Interpolates {var} references (e.g. {approval_path}).',
184
+ }),
173
185
  cfg('fields', 'Fields', 'objectList', {
174
- help: 'Fields presented on this screen.',
186
+ help: 'Input fields collected on this screen. Leave empty for a message-only screen.',
175
187
  columns: [
176
188
  { key: 'name', label: 'Name', kind: 'text', placeholder: 'discount' },
177
189
  { key: 'label', label: 'Label', kind: 'text', placeholder: 'Discount %' },
@@ -180,6 +192,29 @@ const FLOW_NODE_CONFIG = {
180
192
  { key: 'visibleWhen', label: 'Visible when', kind: 'expression', placeholder: 'stage == "review"' },
181
193
  ],
182
194
  }),
195
+ cfg('waitForInput', 'Wait for input', 'boolean', {
196
+ help: 'Pause to show this screen even with no fields (a message / confirmation). A field-less screen with this off is a server pass-through.',
197
+ }),
198
+ cfg('objectName', 'Object form', 'reference', {
199
+ ref: { kind: 'object' },
200
+ placeholder: 'crm_account',
201
+ help: 'Render this object\u2019s full create/edit form (incl. master-detail) instead of a flat field list.',
202
+ }),
203
+ cfg('idVariable', 'Saved-record variable', 'text', {
204
+ placeholder: 'account_id',
205
+ help: 'Object form only: variable bound to the saved record\u2019s id, for later steps.',
206
+ }),
207
+ cfg('mode', 'Form mode', 'select', {
208
+ options: [
209
+ { value: 'create', label: 'Create' },
210
+ { value: 'edit', label: 'Edit' },
211
+ ],
212
+ defaultValue: 'create',
213
+ help: 'Object form only.',
214
+ }),
215
+ cfg('defaults', 'Form defaults', 'keyValue', {
216
+ help: 'Object form only: prefilled values (e.g. account \u2192 {account_id}).',
217
+ }),
183
218
  ],
184
219
  // Approval node (ADR-0019). The node opens an approval request on entry,
185
220
  // suspends the run, and resumes down its `approve` / `reject` out-edge once a
@@ -260,6 +295,15 @@ const FLOW_NODE_CONFIG = {
260
295
  },
261
296
  { id: 'escalation.escalateTo', path: ['config', 'escalation', 'escalateTo'], label: 'Escalate to', kind: 'reference', ref: { kind: 'role' }, placeholder: 'user id / role / manager level', showWhen: { field: 'escalation.enabled', equals: ['true'] } },
262
297
  { id: 'escalation.notifySubmitter', path: ['config', 'escalation', 'notifySubmitter'], label: 'Notify submitter', kind: 'boolean', showWhen: { field: 'escalation.enabled', equals: ['true'] } },
298
+ // ADR-0044 send-back-for-revision guard. Surfaces from the engine's
299
+ // published configSchema when online; this hardcoded copy keeps it visible
300
+ // offline / on an older backend. Only meaningful once the node has a
301
+ // `revise` out-edge (author one via the canvas "add revision loop").
302
+ cfg('maxRevisions', 'Max revisions', 'number', {
303
+ placeholder: '3',
304
+ defaultValue: '3',
305
+ help: 'Max send-backs for revision before the request auto-rejects (0 disables send-back). Needs a "revise" out-edge to take effect.',
306
+ }),
263
307
  ],
264
308
  wait: [
265
309
  at('waitEventConfig', 'eventType', 'Wait for', 'select', {
@@ -368,6 +412,7 @@ const FLOW_NODE_CONFIG = {
368
412
  */
369
413
  const TYPE_ALIASES = {
370
414
  action: 'legacy_action',
415
+ http: 'http_request',
371
416
  branch: 'decision',
372
417
  gateway: 'decision',
373
418
  condition: 'decision',
@@ -0,0 +1,22 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+ /**
9
+ * Render a (possibly nested) validation issue path into a human-readable trail
10
+ * that names the offending element. A Zod issue on a dashboard widget arrives as
11
+ * a dot-joined path like `widgets.2.layout`; shown as just its head field
12
+ * ("Widgets") the author can't tell WHICH widget or sub-field is at fault. This
13
+ * turns it into "Widgets → priority_split → layout" by resolving each array
14
+ * index to the item's stable identity (id/name/title) from the draft value.
15
+ *
16
+ * @param headLabel resolved human label for the first segment (caller knows the
17
+ * form/schema labels).
18
+ * @param path dot-joined issue path (e.g. `widgets.2.layout`).
19
+ * @param rootValue the draft object the path indexes into (used to resolve an
20
+ * array index to the item's identity).
21
+ */
22
+ export declare function describeIssuePath(headLabel: string, path: string, rootValue: unknown): string;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+ /**
9
+ * Render a (possibly nested) validation issue path into a human-readable trail
10
+ * that names the offending element. A Zod issue on a dashboard widget arrives as
11
+ * a dot-joined path like `widgets.2.layout`; shown as just its head field
12
+ * ("Widgets") the author can't tell WHICH widget or sub-field is at fault. This
13
+ * turns it into "Widgets → priority_split → layout" by resolving each array
14
+ * index to the item's stable identity (id/name/title) from the draft value.
15
+ *
16
+ * @param headLabel resolved human label for the first segment (caller knows the
17
+ * form/schema labels).
18
+ * @param path dot-joined issue path (e.g. `widgets.2.layout`).
19
+ * @param rootValue the draft object the path indexes into (used to resolve an
20
+ * array index to the item's identity).
21
+ */
22
+ export function describeIssuePath(headLabel, path, rootValue) {
23
+ const segments = path.split('.');
24
+ if (segments.length <= 1)
25
+ return headLabel;
26
+ const parts = [headLabel];
27
+ let cursor = asRecord(rootValue)?.[segments[0]];
28
+ for (let i = 1; i < segments.length; i++) {
29
+ const seg = segments[i];
30
+ if (/^\d+$/.test(seg)) {
31
+ const idx = Number(seg);
32
+ const item = Array.isArray(cursor) ? cursor[idx] : undefined;
33
+ // 1-based index reads naturally for non-developers ("#1" not "#0").
34
+ parts.push(itemIdentity(item) ?? `#${idx + 1}`);
35
+ cursor = item;
36
+ }
37
+ else {
38
+ parts.push(seg);
39
+ cursor = asRecord(cursor)?.[seg];
40
+ }
41
+ }
42
+ return parts.join(' → ');
43
+ }
44
+ /** Best-effort stable identity of an array item, resolving an I18nLabel object
45
+ * ({ key, defaultValue }) to its string. Returns undefined when none usable. */
46
+ function itemIdentity(item) {
47
+ const o = asRecord(item);
48
+ if (!o)
49
+ return undefined;
50
+ for (const k of ['id', 'name', 'key', 'title', 'label']) {
51
+ const v = o[k];
52
+ if (typeof v === 'string' && v.trim())
53
+ return v;
54
+ const nested = asRecord(v);
55
+ if (nested) {
56
+ const s = nested.defaultValue ?? nested.key;
57
+ if (typeof s === 'string' && s.trim())
58
+ return s;
59
+ }
60
+ }
61
+ return undefined;
62
+ }
63
+ function asRecord(v) {
64
+ return v && typeof v === 'object' ? v : undefined;
65
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Sentinel "package" id for this environment's runtime, DB-authored metadata —
3
+ * items with no code-package binding (`package_id IS NULL`). The metadata
4
+ * list/get API treats `?package=sys_metadata` as exactly that local scope on
5
+ * READ, and a WRITE under it persists `package_id = null` (matching the
6
+ * server's runtime-only provenance, see framework #2252).
7
+ *
8
+ * Why this exists: a self-hosted, metadata-customizable environment is
9
+ * single-tenant — there is no "org" dimension here; the real axis is
10
+ * code-package vs. runtime (DB-authored). Before this scope, the package
11
+ * selector only listed code packages, so metadata authored at runtime
12
+ * (`package_id = null`) was filtered out of every code-package view and became
13
+ * un-navigable (the route redirected to "new"). Surfacing the local scope as a
14
+ * first-class, always-present selector entry makes it discoverable and editable.
15
+ */
16
+ export declare const LOCAL_PACKAGE_ID = "sys_metadata";
17
+ /**
18
+ * Build the Studio package-scope options from the raw `package` metadata list.
19
+ * Filters out system/cloud-scoped packages and appends a stable
20
+ * "Local / Custom (this environment)" scope so runtime metadata authored here
21
+ * is always selectable/visible — even when zero items exist yet.
22
+ */
23
+ export declare function buildPackageScopeOptions(rawList: unknown[] | null | undefined): {
24
+ id: string;
25
+ name: string;
26
+ }[];
@@ -0,0 +1,43 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+ import { detectLocale, t } from './i18n';
3
+ /**
4
+ * Sentinel "package" id for this environment's runtime, DB-authored metadata —
5
+ * items with no code-package binding (`package_id IS NULL`). The metadata
6
+ * list/get API treats `?package=sys_metadata` as exactly that local scope on
7
+ * READ, and a WRITE under it persists `package_id = null` (matching the
8
+ * server's runtime-only provenance, see framework #2252).
9
+ *
10
+ * Why this exists: a self-hosted, metadata-customizable environment is
11
+ * single-tenant — there is no "org" dimension here; the real axis is
12
+ * code-package vs. runtime (DB-authored). Before this scope, the package
13
+ * selector only listed code packages, so metadata authored at runtime
14
+ * (`package_id = null`) was filtered out of every code-package view and became
15
+ * un-navigable (the route redirected to "new"). Surfacing the local scope as a
16
+ * first-class, always-present selector entry makes it discoverable and editable.
17
+ */
18
+ export const LOCAL_PACKAGE_ID = 'sys_metadata';
19
+ const SYSTEM_SCOPES = new Set(['system', 'cloud']);
20
+ /**
21
+ * Build the Studio package-scope options from the raw `package` metadata list.
22
+ * Filters out system/cloud-scoped packages and appends a stable
23
+ * "Local / Custom (this environment)" scope so runtime metadata authored here
24
+ * is always selectable/visible — even when zero items exist yet.
25
+ */
26
+ export function buildPackageScopeOptions(rawList) {
27
+ const rows = (rawList ?? [])
28
+ .map((raw) => {
29
+ const item = raw && typeof raw === 'object' && 'item' in raw ? raw.item : raw;
30
+ const m = (item?.manifest ?? item ?? {});
31
+ return {
32
+ id: m.id,
33
+ scope: m.scope,
34
+ name: m.name || m.id,
35
+ };
36
+ })
37
+ .filter((p) => p.id && !SYSTEM_SCOPES.has(p.scope));
38
+ rows.sort((a, b) => a.name.localeCompare(b.name));
39
+ const opts = rows.map((p) => ({ id: p.id, name: p.name }));
40
+ // Append (never default) so the existing first-code-package default is
41
+ // preserved; the user opts into the local scope explicitly.
42
+ return [...opts, { id: LOCAL_PACKAGE_ID, name: t('engine.package.local', detectLocale()) }];
43
+ }
@@ -77,13 +77,29 @@ export function DatasetPreview({ draft }) {
77
77
  const resultObject = state.status === 'ok' ? state.object : undefined;
78
78
  const { measureField, headerLabel } = buildDatasetFieldHelpers(resultFields, resultObject, fieldLabel);
79
79
  const columns = [...dimensionNames, ...measureNames];
80
+ // A ratio/percent measure (format like `0.0%`) on the same axis as a
81
+ // magnitude measure (currency in the hundred-thousands) renders as an
82
+ // invisible sliver. When the selection MIXES the two scales, plot the ratio
83
+ // measures as a line on a secondary (right) Y axis via the `combo` chart —
84
+ // bars (magnitude) keep the left axis. Same-scale selections stay a plain bar.
85
+ const isRatioMeasure = (m) => {
86
+ const f = measureField(m)?.format;
87
+ return typeof f === 'string' && f.includes('%');
88
+ };
89
+ const ratioMeasures = measureNames.filter(isRatioMeasure);
90
+ const mixedScale = ratioMeasures.length > 0 && ratioMeasures.length < measureNames.length;
80
91
  return (_jsx(PreviewShell, { hint: `dataset · ${objectName}${dimensionNames.length ? ' · by ' + dimensionNames.join(', ') : ''}`, children: _jsxs("div", { className: "p-3 space-y-2", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsxs("button", { type: "button", onClick: () => void run(), disabled: state.status === 'loading', className: "inline-flex items-center gap-1.5 rounded-md border bg-background px-3 py-1.5 text-xs font-medium hover:bg-accent disabled:opacity-50", children: [state.status === 'loading'
81
92
  ? _jsx(Loader2, { className: "h-3.5 w-3.5 animate-spin" })
82
- : _jsx(BarChart3, { className: "h-3.5 w-3.5" }), "Run preview"] }), _jsxs("span", { className: "text-[11px] text-muted-foreground", children: [measureNames.length, " measure", measureNames.length === 1 ? '' : 's', " \u00B7 ", dimensionNames.length, " dimension", dimensionNames.length === 1 ? '' : 's'] })] }), state.status === 'error' && (_jsxs("div", { role: "alert", className: "flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive", children: [_jsx(AlertTriangle, { className: "h-3.5 w-3.5 mt-0.5 shrink-0" }), _jsx("span", { className: "break-words", children: state.error })] })), state.status === 'ok' && state.rows.length === 0 && (_jsx(PreviewEmptyState, { icon: _jsx(BarChart3, { className: "h-8 w-8" }), title: "No rows", description: "The dataset returned no rows for the current scope." })), state.rows.length > 0 && dimensionNames.length >= 1 && (_jsx(PreviewErrorBoundary, { fallbackHint: "Couldn't render the chart for this result \u2014 the table below still shows the data.", children: _jsx(React.Suspense, { fallback: _jsx("div", { className: "h-[260px] flex items-center justify-center text-xs text-muted-foreground", children: _jsx(Loader2, { className: "h-4 w-4 animate-spin" }) }), children: _jsx("div", { className: "rounded-md border p-2", children: _jsx(ChartRenderer, { schema: {
83
- data: state.rows,
84
- xAxisKey: dimensionNames[0],
85
- series: measureNames.map((m) => ({ dataKey: m, label: headerLabel(m), chartType: 'bar' })),
86
- } }) }) }) })), state.rows.length > 0 && (_jsx("div", { className: "overflow-auto max-h-[60vh] rounded-md border", children: _jsxs("table", { className: "w-full text-xs", children: [_jsx("thead", { className: "bg-muted/40", children: _jsx("tr", { children: columns.map((c) => (_jsx("th", { className: "px-2 py-1.5 text-left font-medium whitespace-nowrap", children: headerLabel(c) }, c))) }) }), _jsx("tbody", { children: state.rows.map((row, i) => (_jsx("tr", { className: "border-t", children: columns.map((c) => (_jsx("td", { className: "px-2 py-1 tabular-nums whitespace-nowrap", children: measureNames.includes(c)
93
+ : _jsx(BarChart3, { className: "h-3.5 w-3.5" }), "Run preview"] }), _jsxs("span", { className: "text-[11px] text-muted-foreground", children: [measureNames.length, " measure", measureNames.length === 1 ? '' : 's', " \u00B7 ", dimensionNames.length, " dimension", dimensionNames.length === 1 ? '' : 's'] })] }), state.status === 'error' && (_jsxs("div", { role: "alert", className: "flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive", children: [_jsx(AlertTriangle, { className: "h-3.5 w-3.5 mt-0.5 shrink-0" }), _jsx("span", { className: "break-words", children: state.error })] })), state.status === 'ok' && state.rows.length === 0 && (_jsx(PreviewEmptyState, { icon: _jsx(BarChart3, { className: "h-8 w-8" }), title: "No rows", description: "The dataset returned no rows for the current scope." })), state.rows.length > 0 && dimensionNames.length >= 1 && (_jsx(PreviewErrorBoundary, { fallbackHint: "Couldn't render the chart for this result \u2014 the table below still shows the data.", children: _jsx(React.Suspense, { fallback: _jsx("div", { className: "h-[260px] flex items-center justify-center text-xs text-muted-foreground", children: _jsx(Loader2, { className: "h-4 w-4 animate-spin" }) }), children: _jsxs("div", { className: "rounded-md border p-2", children: [_jsx(ChartRenderer, { schema: {
94
+ data: state.rows,
95
+ xAxisKey: dimensionNames[0],
96
+ chartType: mixedScale ? 'combo' : 'bar',
97
+ series: measureNames.map((m) => ({
98
+ dataKey: m,
99
+ label: headerLabel(m),
100
+ chartType: mixedScale ? (isRatioMeasure(m) ? 'line' : 'bar') : 'bar',
101
+ })),
102
+ } }), mixedScale && (_jsxs("p", { className: "mt-1 px-1 text-[10px] text-muted-foreground", children: ["Ratio measures (", ratioMeasures.map(headerLabel).join(', '), ") use the right axis."] }))] }) }) })), state.rows.length > 0 && (_jsx("div", { className: "overflow-auto max-h-[60vh] rounded-md border", children: _jsxs("table", { className: "w-full text-xs", children: [_jsx("thead", { className: "bg-muted/40", children: _jsx("tr", { children: columns.map((c) => (_jsx("th", { className: "px-2 py-1.5 text-left font-medium whitespace-nowrap", children: headerLabel(c) }, c))) }) }), _jsx("tbody", { children: state.rows.map((row, i) => (_jsx("tr", { className: "border-t", children: columns.map((c) => (_jsx("td", { className: "px-2 py-1 tabular-nums whitespace-nowrap", children: measureNames.includes(c)
87
103
  ? formatMeasure(row[c], measureField(c)?.format, measureField(c)?.currency)
88
104
  : formatDimensionValue(row[c]) }, c))) }, i))) })] }) }))] }) }));
89
105
  }
@@ -35,9 +35,15 @@ export interface FlowCanvasProps {
35
35
  visitedNodeIds?: string[];
36
36
  /** Simulation overlay: ids of edges that were traversed. */
37
37
  traversedEdgeIds?: string[];
38
+ /** Structural-validation: node ids to paint with a red error ring. */
39
+ invalidNodeIds?: string[];
40
+ /** Structural-validation: edges (keyed `${source}->${target}`) to paint red. */
41
+ invalidEdges?: ReadonlySet<string>;
42
+ /** Structural-validation error messages shown in an inline canvas banner. */
43
+ validationErrors?: string[];
38
44
  onSelect: (node: FlowNode | null) => void;
39
45
  /** Select an edge (its `edgeKey`), or clear selection with `null`. */
40
46
  onSelectEdge?: (edge: FlowEdge | null, key: string) => void;
41
47
  onPatch?: (partial: Record<string, unknown>) => void;
42
48
  }
43
- export declare function FlowCanvas({ nodes, edges, editable, designMode, selectedId, selectedEdgeId, locale, activeNodeId, visitedNodeIds, traversedEdgeIds, onSelect, onSelectEdge, onPatch, }: FlowCanvasProps): React.JSX.Element;
49
+ export declare function FlowCanvas({ nodes, edges, editable, designMode, selectedId, selectedEdgeId, locale, activeNodeId, visitedNodeIds, traversedEdgeIds, invalidNodeIds, invalidEdges, validationErrors, onSelect, onSelectEdge, onPatch, }: FlowCanvasProps): React.JSX.Element;
@@ -21,17 +21,17 @@ 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 { 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, 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
31
  const MIN_ZOOM = 0.4;
32
32
  const MAX_ZOOM = 1.6;
33
33
  const DRAG_THRESHOLD = 4;
34
- export function FlowCanvas({ nodes, edges, editable, designMode, selectedId, selectedEdgeId, locale, activeNodeId, visitedNodeIds, traversedEdgeIds, onSelect, onSelectEdge, onPatch, }) {
34
+ export function FlowCanvas({ nodes, edges, editable, designMode, selectedId, selectedEdgeId, locale, activeNodeId, visitedNodeIds, traversedEdgeIds, invalidNodeIds, invalidEdges, validationErrors, onSelect, onSelectEdge, onPatch, }) {
35
35
  const viewportRef = React.useRef(null);
36
36
  const [zoom, setZoom] = React.useState(1);
37
37
  const [pan, setPan] = React.useState({ x: 0, y: 0 });
@@ -51,6 +51,7 @@ export function FlowCanvas({ nodes, edges, editable, designMode, selectedId, sel
51
51
  // Simulation overlay sets (display-only; never drives engine behavior).
52
52
  const visitedSet = React.useMemo(() => new Set(visitedNodeIds ?? []), [visitedNodeIds]);
53
53
  const traversedSet = React.useMemo(() => new Set(traversedEdgeIds ?? []), [traversedEdgeIds]);
54
+ const invalidNodeSet = React.useMemo(() => new Set(invalidNodeIds ?? []), [invalidNodeIds]);
54
55
  const simRunning = (visitedNodeIds?.length ?? 0) > 0 || !!activeNodeId;
55
56
  const positionOf = React.useCallback((id) => {
56
57
  if (dragPos && dragPos.id === id)
@@ -88,6 +89,28 @@ export function FlowCanvas({ nodes, edges, editable, designMode, selectedId, sel
88
89
  source: opts.from,
89
90
  target: id,
90
91
  };
92
+ // When the source is a decision, carry its matching branch (by order:
93
+ // the k-th out-edge takes the k-th branch) onto the new edge so it
94
+ // actually routes. The decision's config.conditions are otherwise
95
+ // disconnected from the edges, leaving every branch unconditional.
96
+ const fromNode = nodes.find((n) => n.id === opts.from);
97
+ if (fromNode?.type === 'decision') {
98
+ const branches = Array.isArray(fromNode.config?.conditions)
99
+ ? fromNode.config.conditions
100
+ : [];
101
+ const outCount = edges.filter((e) => e.source === opts.from).length;
102
+ const branch = branches[outCount];
103
+ if (branch && typeof branch === 'object') {
104
+ const expr = typeof branch.expression === 'string' ? branch.expression.trim() : '';
105
+ const label = typeof branch.label === 'string' ? branch.label.trim() : '';
106
+ if (label)
107
+ newEdge.label = label;
108
+ if (expr === 'true')
109
+ newEdge.isDefault = true;
110
+ else if (expr)
111
+ newEdge.condition = expr;
112
+ }
113
+ }
91
114
  patch.edges = appendArray(edges, newEdge);
92
115
  }
93
116
  onPatch(patch);
@@ -124,6 +147,49 @@ export function FlowCanvas({ nodes, edges, editable, designMode, selectedId, sel
124
147
  onPatch({ nodes: appendArray(nodes, newNode), edges: appendArray(nextEdges, secondSegment) });
125
148
  onSelect(newNode);
126
149
  }, [edges, nodes, onPatch, onSelect, positionOf]);
150
+ /**
151
+ * ADR-0044 one-click "add revision loop": drop a signal `wait` node plus the
152
+ * two edges that form a send-back-for-revision loop on an approval node —
153
+ * a `revise` out-edge to the wait point, and a declared `back`-edge closing
154
+ * the loop (resubmit re-enters the approval node as round N+1). Reproduces the
155
+ * canonical `showcase_budget_approval` shape in a single gesture. The wait
156
+ * node is left unpinned so the layered auto-layout slots it among the
157
+ * approval node's other branches.
158
+ */
159
+ const addReviseLoop = React.useCallback((approvalId) => {
160
+ if (!onPatch)
161
+ return;
162
+ if (!nodes.some((n) => n.id === approvalId))
163
+ return;
164
+ const waitId = uniqueId('node', nodes.map((n) => n.id).filter(Boolean));
165
+ const waitNode = {
166
+ id: waitId,
167
+ type: 'wait',
168
+ label: 'Awaiting Revision',
169
+ // Signal-flavored wait: the submitter's resubmit signal resumes the run.
170
+ waitEventConfig: { eventType: 'signal', signalName: 'revision', onTimeout: 'fail' },
171
+ };
172
+ const existingEdgeIds = edges.map((e) => e.id).filter(Boolean);
173
+ const reviseId = uniqueId('edge', existingEdgeIds);
174
+ const backId = uniqueId('edge', [...existingEdgeIds, reviseId]);
175
+ const reviseEdge = { id: reviseId, source: approvalId, target: waitId, label: 'revise' };
176
+ const backEdge = { id: backId, source: waitId, target: approvalId, label: 'resubmit', type: 'back' };
177
+ onPatch({
178
+ nodes: appendArray(nodes, waitNode),
179
+ edges: appendArray(appendArray(edges, reviseEdge), backEdge),
180
+ });
181
+ onSelect(waitNode);
182
+ }, [edges, nodes, onPatch, onSelect]);
183
+ // Approval nodes that already declare a `revise` out-edge — used to hide the
184
+ // "add revision loop" affordance once a loop exists (avoid duplicates).
185
+ const reviseLoopSources = React.useMemo(() => {
186
+ const s = new Set();
187
+ for (const e of edges) {
188
+ if (typeof e.label === 'string' && e.label.trim().toLowerCase() === 'revise')
189
+ s.add(e.source);
190
+ }
191
+ return s;
192
+ }, [edges]);
127
193
  const deleteNode = React.useCallback((id) => {
128
194
  if (!onPatch)
129
195
  return;
@@ -220,7 +286,7 @@ export function FlowCanvas({ nodes, edges, editable, designMode, selectedId, sel
220
286
  }
221
287
  }, [deleteNode, editable, selectedId]);
222
288
  // ── 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) => {
289
+ return (_jsxs("div", { className: "relative h-full min-h-[320px] w-full overflow-hidden", children: [validationErrors && validationErrors.length > 0 && (_jsxs("div", { className: "absolute left-2 top-2 z-30 max-w-[min(60%,420px)] space-y-1", children: [validationErrors.slice(0, 3).map((msg, i) => (_jsxs("div", { role: "alert", className: "flex items-start gap-1.5 rounded-lg border border-destructive/40 bg-destructive/10 px-2.5 py-1.5 text-[11px] leading-snug text-destructive shadow-sm backdrop-blur-sm", children: [_jsx(AlertTriangle, { className: "mt-0.5 h-3.5 w-3.5 shrink-0" }), _jsx("span", { children: msg })] }, i))), validationErrors.length > 3 && (_jsxs("div", { className: "px-2.5 text-[10px] text-destructive/80", children: ["+", validationErrors.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
290
  onBgPointerMove(e);
225
291
  onNodePointerMove(e);
226
292
  }, onPointerUp: (e) => {
@@ -238,44 +304,66 @@ export function FlowCanvas({ nodes, edges, editable, designMode, selectedId, sel
238
304
  width: size.width,
239
305
  height: size.height,
240
306
  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) => {
307
+ }, 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
308
  const sp = layout.get(edge.source);
243
309
  const tp = layout.get(edge.target);
244
310
  if (!sp || !tp)
245
311
  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);
312
+ // ADR-0044 back-edges (revise loop) re-enter an earlier node, so
313
+ // they attach to the right side of both endpoints and render as a
314
+ // dashed amber return arc — visually distinct from the forward
315
+ // top-to-bottom flow.
316
+ const back = isBackEdge(edge);
317
+ // Structural-validation error (e.g. part of an un-declared cycle).
318
+ // Back-edges are excluded from cycle detection, so they're never invalid.
319
+ const invalid = !back && !!invalidEdges?.has(`${edge.source}->${edge.target}`);
320
+ const sPos = dragPos?.id === edge.source ? positionOf(edge.source) : sp;
321
+ const tPos = dragPos?.id === edge.target ? positionOf(edge.target) : tp;
322
+ const from = back ? rightAnchor(sPos) : bottomAnchor(sPos);
323
+ const to = back ? rightAnchor(tPos) : topAnchor(tPos);
324
+ const labelPos = back ? backEdgeLabelAnchor(from, to) : edgeMidpoint(from, to);
249
325
  const cond = conditionText(edge.condition);
250
326
  const branchLabel = edge.isDefault ? 'else' : cond ? `if ${cond}` : edge.label;
251
327
  const eid = edgeKey(edge, i);
252
328
  const traversed = traversedSet.has(eid);
253
329
  const selected = selectedEdgeId === eid;
254
- const d = edgePath(from, to);
330
+ const d = back ? backEdgePath(from, to) : edgePath(from, to);
255
331
  // Edges are selectable in design mode; the host opens the edge
256
332
  // inspector. A wide transparent hit-path widens the click target
257
333
  // beyond the 1.5px visible stroke without altering the visuals.
258
334
  const selectable = designMode && !!onSelectEdge;
259
- return (_jsxs("g", { children: [_jsx("path", { d: d, strokeLinecap: "round", className: cn('fill-none transition-[stroke] duration-150', traversed
335
+ 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
336
  ? 'stroke-sky-500'
261
337
  : selected
262
338
  ? '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) => {
339
+ : invalid
340
+ ? 'stroke-destructive'
341
+ : back
342
+ ? 'stroke-amber-500/70'
343
+ : simRunning
344
+ ? 'stroke-muted-foreground/20'
345
+ : '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
346
  e.stopPropagation();
267
347
  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", {
348
+ }, 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
349
+ ? 'border-primary text-primary'
350
+ : invalid
351
+ ? 'border-destructive/60 text-destructive'
352
+ : back
353
+ ? 'border-amber-500/50 text-amber-600 dark:text-amber-400'
354
+ : 'border-border text-muted-foreground'), children: branchLabel }) }) })), editable && !back && (_jsx("foreignObject", {
269
355
  // Sit the insert handle at the edge midpoint, but slide it
270
356
  // to the right of the branch-label pill when one is present
271
357
  // 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) => {
358
+ 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
359
  e.stopPropagation();
274
360
  insertOnEdge(edge);
275
361
  }, 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
362
  })] }), nodes.map((node) => {
277
363
  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));
364
+ 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)
365
+ ? () => addReviseLoop(node.id)
366
+ : undefined, invalid: invalidNodeSet.has(node.id) }, node.id));
279
367
  })] }) })] }));
280
368
  }
281
369
  /** One-line config summary shown on the node card (best-effort, type-aware). */
@@ -25,10 +25,13 @@ 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
+ import { validateFlowDraft } from './simulator/flow-sim-validate';
28
29
  export function FlowPreview({ draft, editing, selection, onSelectionChange, onPatch, locale }) {
29
30
  const d = draft;
30
- const nodes = Array.isArray(d.nodes) ? d.nodes : [];
31
- const edges = Array.isArray(d.edges) ? d.edges : [];
31
+ // Memoized so hook deps (validation memo, handleAddNode) get a stable array
32
+ // reference across renders instead of a fresh `[]`/cast each time.
33
+ const nodes = React.useMemo(() => (Array.isArray(d.nodes) ? d.nodes : []), [d.nodes]);
34
+ const edges = React.useMemo(() => (Array.isArray(d.edges) ? d.edges : []), [d.edges]);
32
35
  const variables = Array.isArray(d.variables) ? d.variables : [];
33
36
  const designMode = !!(editing && onSelectionChange);
34
37
  const canEdit = designMode && !!onPatch;
@@ -38,6 +41,31 @@ export function FlowPreview({ draft, editing, selection, onSelectionChange, onPa
38
41
  const [showVars, setShowVars] = React.useState(true);
39
42
  const [showRuns, setShowRuns] = React.useState(false);
40
43
  const [runHL, setRunHL] = React.useState(null);
44
+ // Continuous structural validation surfaced INLINE on the canvas (ADR-0044):
45
+ // an un-declared cycle (and other structural errors) paints the offending
46
+ // edges/nodes red and shows a banner — so the author sees it without opening
47
+ // the Debug panel. Same `validateFlowDraft` the simulator preflight uses.
48
+ const { invalidNodeIds, invalidEdges, validationErrors } = React.useMemo(() => {
49
+ const v = validateFlowDraft(nodes, edges);
50
+ const nodeSet = new Set();
51
+ const edgeSet = new Set();
52
+ for (const diag of v.errors) {
53
+ if (diag.nodeId)
54
+ nodeSet.add(diag.nodeId);
55
+ // A cycle error carries its closing node path → mark each hop's edge red.
56
+ if (diag.cycle) {
57
+ for (let i = 0; i < diag.cycle.length - 1; i++) {
58
+ nodeSet.add(diag.cycle[i]);
59
+ edgeSet.add(`${diag.cycle[i]}->${diag.cycle[i + 1]}`);
60
+ }
61
+ }
62
+ }
63
+ return {
64
+ invalidNodeIds: [...nodeSet],
65
+ invalidEdges: edgeSet,
66
+ validationErrors: v.errors.map((diag) => diag.message),
67
+ };
68
+ }, [nodes, edges]);
41
69
  const handleAddNode = React.useCallback(() => {
42
70
  if (!canEdit)
43
71
  return;
@@ -76,7 +104,7 @@ export function FlowPreview({ draft, editing, selection, onSelectionChange, onPa
76
104
  }, className: 'inline-flex items-center gap-1 rounded border px-2 py-0.5 text-[11px] font-medium transition-colors ' +
77
105
  (showDebug
78
106
  ? '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
107
+ : '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, validationErrors: validationErrors, onSelect: (n) => n
80
108
  ? onSelectionChange?.({ kind: 'node', id: n.id, label: n.label || n.id })
81
109
  : onSelectionChange?.(null), onSelectEdge: (e, key) => e
82
110
  ? onSelectionChange?.({ kind: 'edge', id: key, label: `${e.source} → ${e.target}` })