@bian-womp/spark-workbench 0.2.0 → 0.2.1

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 (45) hide show
  1. package/lib/cjs/index.cjs +476 -115
  2. package/lib/cjs/index.cjs.map +1 -1
  3. package/lib/cjs/src/core/InMemoryWorkbench.d.ts +0 -2
  4. package/lib/cjs/src/core/InMemoryWorkbench.d.ts.map +1 -1
  5. package/lib/cjs/src/core/contracts.d.ts +2 -0
  6. package/lib/cjs/src/core/contracts.d.ts.map +1 -1
  7. package/lib/cjs/src/index.d.ts +1 -0
  8. package/lib/cjs/src/index.d.ts.map +1 -1
  9. package/lib/cjs/src/misc/DefaultNode.d.ts +1 -1
  10. package/lib/cjs/src/misc/DefaultNode.d.ts.map +1 -1
  11. package/lib/cjs/src/misc/Inspector.d.ts.map +1 -1
  12. package/lib/cjs/src/misc/NodeHandles.d.ts +20 -0
  13. package/lib/cjs/src/misc/NodeHandles.d.ts.map +1 -0
  14. package/lib/cjs/src/misc/WorkbenchCanvas.d.ts.map +1 -1
  15. package/lib/cjs/src/misc/WorkbenchStudio.d.ts +2 -2
  16. package/lib/cjs/src/misc/WorkbenchStudio.d.ts.map +1 -1
  17. package/lib/cjs/src/misc/constants.d.ts +3 -0
  18. package/lib/cjs/src/misc/constants.d.ts.map +1 -0
  19. package/lib/cjs/src/misc/hooks.d.ts +2 -2
  20. package/lib/cjs/src/misc/hooks.d.ts.map +1 -1
  21. package/lib/cjs/src/misc/mapping.d.ts +22 -0
  22. package/lib/cjs/src/misc/mapping.d.ts.map +1 -1
  23. package/lib/esm/index.js +473 -114
  24. package/lib/esm/index.js.map +1 -1
  25. package/lib/esm/src/core/InMemoryWorkbench.d.ts +0 -2
  26. package/lib/esm/src/core/InMemoryWorkbench.d.ts.map +1 -1
  27. package/lib/esm/src/core/contracts.d.ts +2 -0
  28. package/lib/esm/src/core/contracts.d.ts.map +1 -1
  29. package/lib/esm/src/index.d.ts +1 -0
  30. package/lib/esm/src/index.d.ts.map +1 -1
  31. package/lib/esm/src/misc/DefaultNode.d.ts +1 -1
  32. package/lib/esm/src/misc/DefaultNode.d.ts.map +1 -1
  33. package/lib/esm/src/misc/Inspector.d.ts.map +1 -1
  34. package/lib/esm/src/misc/NodeHandles.d.ts +20 -0
  35. package/lib/esm/src/misc/NodeHandles.d.ts.map +1 -0
  36. package/lib/esm/src/misc/WorkbenchCanvas.d.ts.map +1 -1
  37. package/lib/esm/src/misc/WorkbenchStudio.d.ts +2 -2
  38. package/lib/esm/src/misc/WorkbenchStudio.d.ts.map +1 -1
  39. package/lib/esm/src/misc/constants.d.ts +3 -0
  40. package/lib/esm/src/misc/constants.d.ts.map +1 -0
  41. package/lib/esm/src/misc/hooks.d.ts +2 -2
  42. package/lib/esm/src/misc/hooks.d.ts.map +1 -1
  43. package/lib/esm/src/misc/mapping.d.ts +22 -0
  44. package/lib/esm/src/misc/mapping.d.ts.map +1 -1
  45. package/package.json +7 -5
package/lib/cjs/index.cjs CHANGED
@@ -3,9 +3,10 @@
3
3
  var sparkGraph = require('@bian-womp/spark-graph');
4
4
  var sparkRemote = require('@bian-womp/spark-remote');
5
5
  var React = require('react');
6
+ var react = require('@xyflow/react');
6
7
  var jsxRuntime = require('react/jsx-runtime');
7
- var react = require('@phosphor-icons/react');
8
- var react$1 = require('@xyflow/react');
8
+ var react$1 = require('@phosphor-icons/react');
9
+ var isEqual = require('lodash/isEqual');
9
10
  var cx = require('classnames');
10
11
 
11
12
  class DefaultUIExtensionRegistry {
@@ -232,6 +233,10 @@ class InMemoryWorkbench extends AbstractWorkbench {
232
233
  setSelection(sel) {
233
234
  this.selection = { nodes: [...sel.nodes], edges: [...sel.edges] };
234
235
  this.emit("selectionChanged", this.selection);
236
+ this.emit("graphUiChanged", {
237
+ def: this.def,
238
+ change: { type: "selection" },
239
+ });
235
240
  }
236
241
  getSelection() {
237
242
  return {
@@ -239,18 +244,6 @@ class InMemoryWorkbench extends AbstractWorkbench {
239
244
  edges: [...this.selection.edges],
240
245
  };
241
246
  }
242
- toggleNodeSelection(nodeId) {
243
- this.selection.nodes = this.selection.nodes.includes(nodeId)
244
- ? this.selection.nodes.filter((id) => id !== nodeId)
245
- : [...this.selection.nodes, nodeId];
246
- this.emit("selectionChanged", this.selection);
247
- }
248
- toggleEdgeSelection(edgeId) {
249
- this.selection.edges = this.selection.edges.includes(edgeId)
250
- ? this.selection.edges.filter((id) => id !== edgeId)
251
- : [...this.selection.edges, edgeId];
252
- this.emit("selectionChanged", this.selection);
253
- }
254
247
  on(event, handler) {
255
248
  if (!this.listeners.has(event))
256
249
  this.listeners.set(event, new Set());
@@ -760,49 +753,119 @@ function useWorkbenchBridge(wb) {
760
753
  });
761
754
  }, [wb]);
762
755
  const onNodesChange = React.useCallback((changes) => {
756
+ // Apply position updates
763
757
  changes.forEach((c) => {
764
- if (c.type === "position" && c.position)
758
+ if (c.type === "position" && c.position) {
765
759
  wb.setPosition(c.id, c.position);
766
- if (c.type === "remove")
767
- wb.removeNode(c.id);
768
- if (c.type === "select")
769
- wb.toggleNodeSelection(c.id);
760
+ }
770
761
  });
762
+ // Derive next node selection from change set
763
+ const current = wb.getSelection();
764
+ const nextNodeIds = new Set(current.nodes);
765
+ let selectionChanged = false;
766
+ for (const change of changes) {
767
+ const type = change?.type;
768
+ if (type === "select") {
769
+ const id = change.id;
770
+ const selected = change.selected;
771
+ if (typeof selected === "boolean") {
772
+ if (selected) {
773
+ if (!nextNodeIds.has(id)) {
774
+ nextNodeIds.add(id);
775
+ selectionChanged = true;
776
+ }
777
+ }
778
+ else if (nextNodeIds.delete(id)) {
779
+ selectionChanged = true;
780
+ }
781
+ }
782
+ }
783
+ else if (type === "selectNodes") {
784
+ const ids = change.ids;
785
+ const selected = change.selected;
786
+ if (Array.isArray(ids) && typeof selected === "boolean") {
787
+ for (const id of ids) {
788
+ if (selected) {
789
+ if (!nextNodeIds.has(id)) {
790
+ nextNodeIds.add(id);
791
+ selectionChanged = true;
792
+ }
793
+ }
794
+ else if (nextNodeIds.delete(id)) {
795
+ selectionChanged = true;
796
+ }
797
+ }
798
+ }
799
+ }
800
+ else if (type === "remove") {
801
+ const id = change.id;
802
+ if (nextNodeIds.delete(id))
803
+ selectionChanged = true;
804
+ }
805
+ }
806
+ if (selectionChanged) {
807
+ wb.setSelection({ nodes: Array.from(nextNodeIds), edges: current.edges });
808
+ }
771
809
  }, [wb]);
772
810
  const onEdgesDelete = React.useCallback((edges) => edges.forEach((e) => wb.disconnect(e.id)), [wb]);
773
811
  const onEdgesChange = React.useCallback((changes) => {
774
- changes.forEach((c) => {
775
- if (c.type === "remove")
776
- wb.disconnect(c.id);
777
- else if (c.type === "select")
778
- wb.toggleEdgeSelection(c.id);
779
- });
812
+ const current = wb.getSelection();
813
+ const nextEdgeIds = new Set(current.edges);
814
+ let selectionChanged = false;
815
+ for (const change of changes) {
816
+ const type = change?.type;
817
+ if (type === "select") {
818
+ const id = change.id;
819
+ const selected = change.selected;
820
+ if (typeof selected === "boolean") {
821
+ if (selected) {
822
+ if (!nextEdgeIds.has(id)) {
823
+ nextEdgeIds.add(id);
824
+ selectionChanged = true;
825
+ }
826
+ }
827
+ else if (nextEdgeIds.delete(id)) {
828
+ selectionChanged = true;
829
+ }
830
+ }
831
+ }
832
+ else if (type === "selectEdges") {
833
+ const ids = change.ids;
834
+ const selected = change.selected;
835
+ if (Array.isArray(ids) && typeof selected === "boolean") {
836
+ for (const id of ids) {
837
+ if (selected) {
838
+ if (!nextEdgeIds.has(id)) {
839
+ nextEdgeIds.add(id);
840
+ selectionChanged = true;
841
+ }
842
+ }
843
+ else if (nextEdgeIds.delete(id)) {
844
+ selectionChanged = true;
845
+ }
846
+ }
847
+ }
848
+ }
849
+ else if (type === "remove") {
850
+ const id = change.id;
851
+ if (nextEdgeIds.delete(id))
852
+ selectionChanged = true;
853
+ }
854
+ }
855
+ if (selectionChanged) {
856
+ wb.setSelection({ nodes: current.nodes, edges: Array.from(nextEdgeIds) });
857
+ }
780
858
  }, [wb]);
781
859
  const onNodesDelete = React.useCallback((nodes) => {
782
860
  for (const n of nodes)
783
861
  wb.removeNode(n.id);
784
862
  }, [wb]);
785
- const onSelectionChange = React.useCallback((sel) => {
786
- const next = {
787
- nodes: sel.nodes.map((n) => n.id),
788
- edges: sel.edges.map((e) => e.id),
789
- };
790
- const cur = wb.getSelection();
791
- const sameLen = cur.nodes.length === next.nodes.length &&
792
- cur.edges.length === next.edges.length;
793
- const same = sameLen &&
794
- cur.nodes.every((id, i) => id === next.nodes[i]) &&
795
- cur.edges.every((id, i) => id === next.edges[i]);
796
- if (!same)
797
- wb.setSelection(next);
798
- }, [wb]);
799
863
  return {
800
864
  onConnect,
801
865
  onNodesChange,
802
866
  onEdgesChange,
803
867
  onEdgesDelete,
804
868
  onNodesDelete,
805
- onSelectionChange,
806
869
  };
807
870
  }
808
871
  function useWorkbenchGraphTick(wb) {
@@ -842,6 +905,39 @@ function useWorkbenchVersionTick(runner) {
842
905
  }, [runner]);
843
906
  return version;
844
907
  }
908
+ function useThrottledValue(value, intervalMs) {
909
+ const [throttled, setThrottled] = React.useState(value);
910
+ const lastSetAtRef = React.useRef(0);
911
+ const timeoutRef = React.useRef(null);
912
+ React.useEffect(() => {
913
+ const now = (typeof performance !== "undefined" && performance.now) ? performance.now() : Date.now();
914
+ const elapsed = now - lastSetAtRef.current;
915
+ if (elapsed >= intervalMs) {
916
+ lastSetAtRef.current = now;
917
+ setThrottled(value);
918
+ }
919
+ else {
920
+ if (timeoutRef.current !== null) {
921
+ window.clearTimeout(timeoutRef.current);
922
+ }
923
+ timeoutRef.current = window.setTimeout(() => {
924
+ lastSetAtRef.current = (typeof performance !== "undefined" && performance.now) ? performance.now() : Date.now();
925
+ setThrottled(value);
926
+ timeoutRef.current = null;
927
+ }, Math.max(0, intervalMs - elapsed));
928
+ }
929
+ return () => { };
930
+ }, [value, intervalMs]);
931
+ React.useEffect(() => {
932
+ return () => {
933
+ if (timeoutRef.current !== null) {
934
+ window.clearTimeout(timeoutRef.current);
935
+ timeoutRef.current = null;
936
+ }
937
+ };
938
+ }, []);
939
+ return throttled;
940
+ }
845
941
  // Query param helpers
846
942
  function setSearchParam(key, val) {
847
943
  if (typeof window === "undefined")
@@ -1002,7 +1098,13 @@ function summarizeDeep(value) {
1002
1098
  return value;
1003
1099
  }
1004
1100
 
1101
+ // Shared UI constants for node layout to keep mapping and rendering in sync
1102
+ const NODE_HEADER_HEIGHT_PX = 24;
1103
+ const NODE_ROW_HEIGHT_PX = 22;
1104
+
1005
1105
  function toReactFlow(def, positions, registry, opts) {
1106
+ const EDGE_STYLE_ERROR = { stroke: "#ef4444", strokeWidth: 2 };
1107
+ const EDGE_STYLE_RUNNING = { stroke: "#3b82f6" };
1006
1108
  const nodeHandleMap = {};
1007
1109
  const nodes = def.nodes.map((n) => {
1008
1110
  const desc = registry.nodes.get(n.typeId);
@@ -1014,6 +1116,35 @@ function toReactFlow(def, positions, registry, opts) {
1014
1116
  inputs: new Set(inputHandles.map((h) => h.id)),
1015
1117
  outputs: new Set(outputHandles.map((h) => h.id)),
1016
1118
  };
1119
+ // Match DefaultNode sizing heuristics to avoid hidden nodes during re-measure
1120
+ const HEADER_SIZE = NODE_HEADER_HEIGHT_PX;
1121
+ const ROW_SIZE = NODE_ROW_HEIGHT_PX;
1122
+ const maxRows = Math.max(inputHandles.length, outputHandles.length);
1123
+ const initialWidth = opts.showValues ? 320 : 240;
1124
+ const initialHeight = HEADER_SIZE + maxRows * ROW_SIZE;
1125
+ // Precompute handle bounds so edges can render immediately without waiting for measurement
1126
+ const handles = [
1127
+ // Inputs on the left as targets
1128
+ ...inputHandles.map((h, i) => ({
1129
+ id: h.id,
1130
+ type: "target",
1131
+ position: react.Position.Left,
1132
+ x: 0,
1133
+ y: HEADER_SIZE + i * ROW_SIZE,
1134
+ width: 1,
1135
+ height: ROW_SIZE + 2,
1136
+ })),
1137
+ // Outputs on the right as sources
1138
+ ...outputHandles.map((h, i) => ({
1139
+ id: h.id,
1140
+ type: "source",
1141
+ position: react.Position.Right,
1142
+ x: initialWidth - 1,
1143
+ y: HEADER_SIZE + i * ROW_SIZE,
1144
+ width: 1,
1145
+ height: ROW_SIZE + 2,
1146
+ })),
1147
+ ];
1017
1148
  return {
1018
1149
  id: n.nodeId,
1019
1150
  data: {
@@ -1021,7 +1152,23 @@ function toReactFlow(def, positions, registry, opts) {
1021
1152
  params: n.params,
1022
1153
  inputHandles,
1023
1154
  outputHandles,
1155
+ handleLayout: [
1156
+ ...inputHandles.map((h, i) => ({
1157
+ id: h.id,
1158
+ type: "target",
1159
+ position: react.Position.Left,
1160
+ y: HEADER_SIZE + i * ROW_SIZE + ROW_SIZE / 2,
1161
+ })),
1162
+ ...outputHandles.map((h, i) => ({
1163
+ id: h.id,
1164
+ type: "source",
1165
+ position: react.Position.Right,
1166
+ y: HEADER_SIZE + i * ROW_SIZE + ROW_SIZE / 2,
1167
+ })),
1168
+ ],
1024
1169
  showValues: opts.showValues,
1170
+ renderWidth: initialWidth,
1171
+ renderHeight: initialHeight,
1025
1172
  inputValues: opts.inputs?.[n.nodeId],
1026
1173
  outputValues: opts.outputs?.[n.nodeId],
1027
1174
  status: opts.nodeStatus?.[n.nodeId],
@@ -1038,6 +1185,11 @@ function toReactFlow(def, positions, registry, opts) {
1038
1185
  selected: opts.selectedNodeIds
1039
1186
  ? opts.selectedNodeIds.has(n.nodeId)
1040
1187
  : undefined,
1188
+ initialWidth,
1189
+ initialHeight,
1190
+ handles,
1191
+ width: initialWidth,
1192
+ height: initialHeight,
1041
1193
  };
1042
1194
  });
1043
1195
  const edges = def.edges
@@ -1054,9 +1206,9 @@ function toReactFlow(def, positions, registry, opts) {
1054
1206
  const hasError = !!st?.lastError;
1055
1207
  const isInvalidEdge = !!opts.edgeValidation?.[e.id];
1056
1208
  const style = hasError || isInvalidEdge
1057
- ? { stroke: "#ef4444", strokeWidth: 2 }
1209
+ ? EDGE_STYLE_ERROR
1058
1210
  : isRunning
1059
- ? { stroke: "#3b82f6" }
1211
+ ? EDGE_STYLE_RUNNING
1060
1212
  : undefined;
1061
1213
  return {
1062
1214
  id: e.id,
@@ -1084,17 +1236,35 @@ function getNodeBorderClassNames(args) {
1084
1236
  const hasValidationWarning = !hasValidationError && issues.length > 0;
1085
1237
  const isRunning = !!status.activeRuns;
1086
1238
  const isInvalid = !!status.invalidated && !isRunning && !hasError;
1087
- const borderWidth = selected ? "border-2" : "border";
1239
+ // Keep border width constant to avoid layout reflow on selection toggles
1240
+ const borderWidth = "border";
1088
1241
  const borderStyle = isInvalid ? "border-dashed" : "border-solid";
1089
- const borderColor = hasError || hasValidationError
1090
- ? "border-red-500"
1242
+ const severity = hasError || hasValidationError
1243
+ ? "red"
1091
1244
  : hasValidationWarning
1092
- ? "border-amber-500"
1245
+ ? "amber"
1093
1246
  : isRunning
1094
- ? "border-blue-500"
1095
- : "border-gray-500 dark:border-gray-400";
1096
- const ring = isRunning ? " ring-2 ring-blue-200 dark:ring-blue-900" : "";
1097
- return `${borderWidth} ${borderStyle} ${borderColor}${ring}`.trim();
1247
+ ? "blue"
1248
+ : "gray";
1249
+ const borderBySeverity = {
1250
+ red: "border-red-500",
1251
+ amber: "border-amber-500",
1252
+ blue: "border-blue-500",
1253
+ gray: "border-gray-500 dark:border-gray-400",
1254
+ };
1255
+ const ringBySeverity = {
1256
+ red: "ring-2 ring-red-300 dark:ring-red-900",
1257
+ amber: "ring-2 ring-amber-300 dark:ring-amber-900",
1258
+ blue: "ring-2 ring-blue-200 dark:ring-blue-900",
1259
+ gray: "ring-2 ring-gray-300 dark:ring-gray-500",
1260
+ };
1261
+ const borderColor = borderBySeverity[severity];
1262
+ const ring = isRunning
1263
+ ? ringBySeverity.blue
1264
+ : selected
1265
+ ? ringBySeverity[severity === "blue" ? "gray" : severity]
1266
+ : "";
1267
+ return [borderWidth, borderStyle, borderColor, ring].join(" ").trim();
1098
1268
  }
1099
1269
 
1100
1270
  const WorkbenchContext = React.createContext(null);
@@ -1588,7 +1758,7 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, children, }) {
1588
1758
 
1589
1759
  function IssueBadge({ level, title, size = 12, className, }) {
1590
1760
  const colorClass = level === "error" ? "text-red-600" : "text-amber-600";
1591
- return (jsxRuntime.jsx("button", { type: "button", className: `inline-flex items-center justify-center shrink-0 ${colorClass} ${className ?? ""}`, title: title, style: { width: size, height: size }, children: level === "error" ? (jsxRuntime.jsx(react.XCircleIcon, { size: size, weight: "fill" })) : (jsxRuntime.jsx(react.WarningCircleIcon, { size: size, weight: "fill" })) }));
1761
+ return (jsxRuntime.jsx("button", { type: "button", className: `inline-flex items-center justify-center shrink-0 ${colorClass} ${className ?? ""}`, title: title, style: { width: size, height: size }, children: level === "error" ? (jsxRuntime.jsx(react$1.XCircleIcon, { size: size, weight: "fill" })) : (jsxRuntime.jsx(react$1.WarningCircleIcon, { size: size, weight: "fill" })) }));
1592
1762
  }
1593
1763
 
1594
1764
  function DebugEvents({ autoScroll, onAutoScrollChange, hideWorkbench, onHideWorkbenchChange, }) {
@@ -1709,7 +1879,7 @@ function Inspector({ debug, autoScroll, hideWorkbench, onAutoScrollChange, onHid
1709
1879
  setOriginals(nextOriginals);
1710
1880
  }, [selectedNodeId, selectedDesc, valuesTick]);
1711
1881
  const widthClass = debug ? "w-[480px]" : "w-[320px]";
1712
- return (jsxRuntime.jsxs("div", { className: `${widthClass} border-l border-gray-300 p-3 flex flex-col h-full min-h-0 overflow-hidden`, children: [contextPanel && (jsxRuntime.jsx("div", { className: "mb-2", children: contextPanel })), jsxRuntime.jsx("div", { className: "font-semibold mb-2", children: "Inspector" }), jsxRuntime.jsx("div", { className: "flex-1 overflow-auto", children: !selectedNode && !selectedEdge ? (jsxRuntime.jsxs("div", { children: [jsxRuntime.jsx("div", { className: "text-gray-500", children: "Select a node or edge." }), globalValidationIssues && globalValidationIssues.length > 0 && (jsxRuntime.jsxs("div", { className: "mt-2 text-xs bg-red-50 border border-red-200 rounded px-2 py-1", children: [jsxRuntime.jsx("div", { className: "font-semibold mb-1", children: "Validation" }), jsxRuntime.jsx("ul", { className: "list-disc ml-4", children: globalValidationIssues.map((m, i) => (jsxRuntime.jsxs("li", { className: "flex items-center gap-1", children: [jsxRuntime.jsx(IssueBadge, { level: m.level, size: 24, className: "w-6 h-6" }), jsxRuntime.jsx("span", { children: `${m.code}: ${m.message}` })] }, i))) })] }))] })) : selectedEdge ? (jsxRuntime.jsxs("div", { children: [jsxRuntime.jsxs("div", { className: "mb-2", children: [jsxRuntime.jsxs("div", { children: ["Edge: ", selectedEdge.id] }), jsxRuntime.jsxs("div", { children: [selectedEdge.source.nodeId, ".", selectedEdge.source.handle, " \u2192", " ", selectedEdge.target.nodeId, ".", selectedEdge.target.handle] }), jsxRuntime.jsxs("div", { children: ["Type: ", selectedEdge.typeId] })] }), selectedEdgeValidation.length > 0 && (jsxRuntime.jsxs("div", { className: "mt-2 text-xs bg-red-50 border border-red-200 rounded px-2 py-1", children: [jsxRuntime.jsx("div", { className: "font-semibold mb-1", children: "Validation" }), jsxRuntime.jsx("ul", { className: "list-disc ml-4", children: selectedEdgeValidation.map((m, i) => (jsxRuntime.jsxs("li", { className: "flex items-center gap-1", children: [jsxRuntime.jsx(IssueBadge, { level: m.level, size: 24, className: "w-6 h-6" }), jsxRuntime.jsx("span", { children: `${m.code}: ${m.message}` })] }, i))) })] }))] })) : (jsxRuntime.jsxs("div", { children: [selectedNode && (jsxRuntime.jsxs("div", { className: "mb-2", children: [jsxRuntime.jsxs("div", { children: ["Node: ", selectedNode.nodeId] }), jsxRuntime.jsxs("div", { children: ["Type: ", selectedNode.typeId] }), !!selectedNodeStatus?.lastError && (jsxRuntime.jsx("div", { className: "mt-2 text-sm text-red-700 bg-red-50 border border-red-200 rounded px-2 py-1 break-words", children: String(selectedNodeStatus.lastError?.message ??
1882
+ return (jsxRuntime.jsxs("div", { className: `${widthClass} border-l border-gray-300 p-3 flex flex-col h-full min-h-0 overflow-hidden`, children: [contextPanel && (jsxRuntime.jsx("div", { className: "mb-2", children: contextPanel })), jsxRuntime.jsx("div", { className: "font-semibold mb-2", children: "Inspector" }), jsxRuntime.jsxs("div", { className: "text-xs text-gray-500 mb-2", children: ["valuesTick: ", valuesTick] }), jsxRuntime.jsx("div", { className: "flex-1 overflow-auto", children: !selectedNode && !selectedEdge ? (jsxRuntime.jsxs("div", { children: [jsxRuntime.jsx("div", { className: "text-gray-500", children: "Select a node or edge." }), globalValidationIssues && globalValidationIssues.length > 0 && (jsxRuntime.jsxs("div", { className: "mt-2 text-xs bg-red-50 border border-red-200 rounded px-2 py-1", children: [jsxRuntime.jsx("div", { className: "font-semibold mb-1", children: "Validation" }), jsxRuntime.jsx("ul", { className: "list-disc ml-4", children: globalValidationIssues.map((m, i) => (jsxRuntime.jsxs("li", { className: "flex items-center gap-1", children: [jsxRuntime.jsx(IssueBadge, { level: m.level, size: 24, className: "w-6 h-6" }), jsxRuntime.jsx("span", { children: `${m.code}: ${m.message}` })] }, i))) })] }))] })) : selectedEdge ? (jsxRuntime.jsxs("div", { children: [jsxRuntime.jsxs("div", { className: "mb-2", children: [jsxRuntime.jsxs("div", { children: ["Edge: ", selectedEdge.id] }), jsxRuntime.jsxs("div", { children: [selectedEdge.source.nodeId, ".", selectedEdge.source.handle, " \u2192", " ", selectedEdge.target.nodeId, ".", selectedEdge.target.handle] }), jsxRuntime.jsxs("div", { children: ["Type: ", selectedEdge.typeId] })] }), selectedEdgeValidation.length > 0 && (jsxRuntime.jsxs("div", { className: "mt-2 text-xs bg-red-50 border border-red-200 rounded px-2 py-1", children: [jsxRuntime.jsx("div", { className: "font-semibold mb-1", children: "Validation" }), jsxRuntime.jsx("ul", { className: "list-disc ml-4", children: selectedEdgeValidation.map((m, i) => (jsxRuntime.jsxs("li", { className: "flex items-center gap-1", children: [jsxRuntime.jsx(IssueBadge, { level: m.level, size: 24, className: "w-6 h-6" }), jsxRuntime.jsx("span", { children: `${m.code}: ${m.message}` })] }, i))) })] }))] })) : (jsxRuntime.jsxs("div", { children: [selectedNode && (jsxRuntime.jsxs("div", { className: "mb-2", children: [jsxRuntime.jsxs("div", { children: ["Node: ", selectedNode.nodeId] }), jsxRuntime.jsxs("div", { children: ["Type: ", selectedNode.typeId] }), !!selectedNodeStatus?.lastError && (jsxRuntime.jsx("div", { className: "mt-2 text-sm text-red-700 bg-red-50 border border-red-200 rounded px-2 py-1 break-words", children: String(selectedNodeStatus.lastError?.message ??
1713
1883
  selectedNodeStatus.lastError) }))] })), jsxRuntime.jsxs("div", { className: "mb-2", children: [jsxRuntime.jsx("div", { className: "font-semibold mb-1", children: "Inputs" }), inputHandles.length === 0 ? (jsxRuntime.jsx("div", { className: "text-gray-500", children: "No inputs" })) : (inputHandles.map((h) => {
1714
1884
  const typeId = sparkGraph.getInputTypeId(selectedDesc?.inputs, h);
1715
1885
  const isLinked = def.edges.some((e) => e.target.nodeId === selectedNodeId &&
@@ -1770,79 +1940,120 @@ function Inspector({ debug, autoScroll, hideWorkbench, onAutoScrollChange, onHid
1770
1940
  })()] }, h))))] }), selectedNodeValidation.length > 0 && (jsxRuntime.jsxs("div", { className: "mt-2 text-xs bg-red-50 border border-red-200 rounded px-2 py-1", children: [jsxRuntime.jsx("div", { className: "font-semibold mb-1", children: "Validation" }), jsxRuntime.jsx("ul", { className: "list-disc ml-4", children: selectedNodeValidation.map((m, i) => (jsxRuntime.jsxs("li", { className: "flex items-center gap-1", children: [jsxRuntime.jsx(IssueBadge, { level: m.level, size: 24, className: "w-6 h-6" }), jsxRuntime.jsx("span", { children: `${m.code}: ${m.message}` })] }, i))) })] }))] })) }), debug && (jsxRuntime.jsx("div", { className: "mt-3 flex-none min-h-0 h-[50%]", children: jsxRuntime.jsx(DebugEvents, { autoScroll: !!autoScroll, hideWorkbench: !!hideWorkbench, onAutoScrollChange: onAutoScrollChange, onHideWorkbenchChange: onHideWorkbenchChange }) }))] }));
1771
1941
  }
1772
1942
 
1943
+ function NodeHandles({ data, isConnectable, inputClassName = "!w-2 !h-2 !bg-gray-600", outputClassName = "!w-2 !h-2 !bg-gray-600", getClassName, renderLabel, labelClassName = "absolute text-[11px] text-gray-700 dark:text-gray-300 pointer-events-none", }) {
1944
+ const layout = data.handleLayout ?? [];
1945
+ const byId = React.useMemo(() => {
1946
+ const m = new Map();
1947
+ for (const h of layout) {
1948
+ m.set(h.id, { position: h.position, y: h.y, type: h.type });
1949
+ }
1950
+ return m;
1951
+ }, [layout]);
1952
+ return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [(data.inputHandles ?? []).map((h) => {
1953
+ const placed = byId.get(h.id);
1954
+ const position = placed?.position ?? react.Position.Left;
1955
+ const y = placed?.y;
1956
+ const cls = getClassName?.({ kind: "input", id: h.id, type: "target" }) ??
1957
+ inputClassName;
1958
+ return (jsxRuntime.jsxs(React.Fragment, { children: [jsxRuntime.jsx(react.Handle, { id: h.id, type: "target", position: position, isConnectable: isConnectable, className: cls, style: y !== undefined ? { top: y } : undefined }), renderLabel && (jsxRuntime.jsx("div", { className: labelClassName + " left-2", style: {
1959
+ top: (y ?? 0) - 8,
1960
+ right: "50%",
1961
+ whiteSpace: "nowrap",
1962
+ overflow: "hidden",
1963
+ textOverflow: "ellipsis",
1964
+ }, children: renderLabel({ kind: "input", id: h.id }) }))] }, h.id));
1965
+ }), (data.outputHandles ?? []).map((h) => {
1966
+ const placed = byId.get(h.id);
1967
+ const position = placed?.position ?? react.Position.Right;
1968
+ const y = placed?.y;
1969
+ const cls = getClassName?.({ kind: "output", id: h.id, type: "source" }) ??
1970
+ outputClassName;
1971
+ return (jsxRuntime.jsxs(React.Fragment, { children: [jsxRuntime.jsx(react.Handle, { id: h.id, type: "source", position: position, isConnectable: isConnectable, className: cls, style: y !== undefined ? { top: y } : undefined }), renderLabel && (jsxRuntime.jsx("div", { className: labelClassName + " right-2", style: {
1972
+ top: (y ?? 0) - 8,
1973
+ left: "50%",
1974
+ textAlign: "right",
1975
+ whiteSpace: "nowrap",
1976
+ overflow: "hidden",
1977
+ textOverflow: "ellipsis",
1978
+ }, children: renderLabel({ kind: "output", id: h.id }) }))] }, h.id));
1979
+ })] }));
1980
+ }
1981
+
1773
1982
  const DefaultNode = React.memo(function DefaultNode({ id, data, selected, isConnectable, }) {
1983
+ const updateNodeInternals = react.useUpdateNodeInternals();
1774
1984
  const { typeId, showValues, inputValues, outputValues, toString } = data;
1775
1985
  const inputEntries = data.inputHandles ?? [];
1776
1986
  const outputEntries = data.outputHandles ?? [];
1987
+ React.useEffect(() => {
1988
+ updateNodeInternals(id);
1989
+ }, [
1990
+ id,
1991
+ inputEntries.length,
1992
+ outputEntries.length,
1993
+ showValues,
1994
+ updateNodeInternals,
1995
+ ]);
1777
1996
  const status = data.status ?? { activeRuns: 0 };
1778
1997
  const validation = data.validation ?? {
1779
1998
  inputs: [],
1780
1999
  outputs: [],
1781
2000
  issues: [],
1782
2001
  };
1783
- const HEADER_SIZE = 24;
1784
- const ROW_SIZE = 22;
1785
- const maxRows = Math.max(inputEntries.length, outputEntries.length);
1786
- const minHeight = HEADER_SIZE + maxRows * ROW_SIZE;
1787
- const minWidth = data.showValues ? 320 : 240;
1788
- const topFor = (i) => HEADER_SIZE + i * ROW_SIZE + ROW_SIZE / 2;
1789
2002
  const hasError = !!status.lastError;
1790
- const hasValidationError = validation.issues.some((i) => i.level === "error");
1791
- const hasValidationWarning = !hasValidationError && validation.issues.length > 0;
1792
2003
  const isRunning = !!status.activeRuns;
1793
- const isInvalid = !!status.invalidated && !isRunning && !hasError;
1794
- // Border color encodes severity; thickness encodes selection; style (dashed) encodes invalidated
1795
- const borderWidth = selected ? "border-2" : "border";
1796
- const borderStyle = isInvalid ? "border-dashed" : "border-solid";
1797
- const borderColor = hasError || hasValidationError
1798
- ? "border-red-500"
1799
- : hasValidationWarning
1800
- ? "border-amber-500"
1801
- : isRunning
1802
- ? "border-blue-500"
1803
- : "border-gray-500 dark:border-gray-400";
1804
- const ringClasses = isRunning
1805
- ? "ring-2 ring-blue-200 dark:ring-blue-900"
1806
- : undefined;
1807
- const borderClasses = cx(borderWidth, borderStyle, borderColor, ringClasses);
2004
+ const containerBorder = getNodeBorderClassNames({
2005
+ selected,
2006
+ status,
2007
+ validation,
2008
+ });
1808
2009
  const pct = Math.round(Math.max(0, Math.min(1, Number(status.progress) || 0)) * 100);
1809
- return (jsxRuntime.jsxs("div", { className: cx("rounded-lg bg-white/70 !dark:bg-stone-900", borderClasses), style: { position: "relative", minHeight: minHeight, minWidth }, children: [jsxRuntime.jsxs("div", { className: "flex h-6 items-center justify-center px-2 border-b border-solid border-gray-500 dark:border-gray-400 text-gray-600 dark:text-gray-300", children: [jsxRuntime.jsx("strong", { className: "flex-1 h-full leading-6 text-xs", children: typeId }), jsxRuntime.jsxs("div", { className: "flex items-center gap-1", children: [hasError && (jsxRuntime.jsx("span", { title: String(status.lastError?.message ?? status.lastError), children: jsxRuntime.jsx(react.XCircleIcon, { size: 12, weight: "fill", className: "text-red-500" }) })), validation.issues && validation.issues.length > 0 && (jsxRuntime.jsx(IssueBadge, { level: validation.issues.some((i) => i.level === "error")
2010
+ return (jsxRuntime.jsxs("div", { className: cx("rounded-lg bg-white/70 !dark:bg-stone-900", containerBorder), style: {
2011
+ position: "relative",
2012
+ minWidth: typeof data.renderWidth === "number" ? data.renderWidth : undefined,
2013
+ minHeight: typeof data.renderHeight === "number" ? data.renderHeight : undefined,
2014
+ }, children: [jsxRuntime.jsxs("div", { className: "flex items-center justify-center px-2 border-b border-solid border-gray-500 dark:border-gray-400 text-gray-600 dark:text-gray-300", style: {
2015
+ maxHeight: NODE_HEADER_HEIGHT_PX,
2016
+ minHeight: NODE_HEADER_HEIGHT_PX,
2017
+ }, children: [jsxRuntime.jsx("strong", { className: "flex-1 h-full text-sm", style: { lineHeight: `${NODE_HEADER_HEIGHT_PX}px` }, children: typeId }), jsxRuntime.jsxs("div", { className: "flex items-center gap-1", children: [hasError && (jsxRuntime.jsx("span", { title: String(status.lastError?.message ?? status.lastError), children: jsxRuntime.jsx(react$1.XCircleIcon, { size: 12, weight: "fill", className: "text-red-500" }) })), validation.issues && validation.issues.length > 0 && (jsxRuntime.jsx(IssueBadge, { level: validation.issues.some((i) => i.level === "error")
1810
2018
  ? "error"
1811
2019
  : "warning", size: 12, className: "w-3 h-3", title: validation.issues
1812
2020
  .map((v) => `${v.code}: ${v.message}`)
1813
- .join("; ") })), jsxRuntime.jsxs("span", { className: "text-[10px] opacity-70", children: ["(", id, ")"] })] })] }), (isRunning || pct > 0) && (jsxRuntime.jsx("div", { className: "h-1 bg-blue-200 dark:bg-blue-900", children: jsxRuntime.jsx("div", { className: "h-1 bg-blue-500 transition-all", style: { width: `${pct}%` } }) })), inputEntries.map((entry, i) => {
1814
- const vIssues = validation.inputs.filter((v) => v.handle === entry.id);
1815
- const hasAny = vIssues.length > 0;
1816
- const hasErr = vIssues.some((v) => v.level === "error");
1817
- const title = vIssues.map((v) => `${v.code}: ${v.message}`).join("; ");
1818
- return (jsxRuntime.jsxs(React.Fragment, { children: [jsxRuntime.jsx(react$1.Handle, { id: entry.id, type: "target", position: react$1.Position.Left, isConnectable: isConnectable, className: cx("!w-3 !h-3 !bg-white !dark:bg-stone-900 !border-gray-500 dark:!border-gray-400", hasAny && (hasErr ? "!border-red-500" : "!border-amber-500")), style: { left: -5, top: topFor(i) } }), jsxRuntime.jsxs("div", { className: "absolute left-2 text-[11px] text-gray-700 dark:text-gray-300 pointer-events-none", style: {
1819
- top: topFor(i) - 8,
1820
- right: "50%",
1821
- whiteSpace: "nowrap",
1822
- overflow: "hidden",
1823
- textOverflow: "ellipsis",
1824
- }, title: `${entry.id}: ${entry.typeId}`, children: [entry.id, hasAny && (jsxRuntime.jsx(IssueBadge, { level: hasErr ? "error" : "warning", size: 12, className: "ml-1", title: title })), showValues && (jsxRuntime.jsx("span", { className: "ml-1 opacity-60", children: toString(entry.typeId, inputValues?.[entry.id]) }))] })] }, `in-${entry.id}`));
1825
- }), outputEntries.map((entry, i) => {
1826
- const vIssues = validation.outputs.filter((v) => v.handle === entry.id);
1827
- const hasAny = vIssues.length > 0;
1828
- const hasErr = vIssues.some((v) => v.level === "error");
1829
- const title = vIssues.map((v) => `${v.code}: ${v.message}`).join("; ");
1830
- const resolved = resolveOutputDisplay(outputValues?.[entry.id], entry.typeId);
1831
- return (jsxRuntime.jsxs(React.Fragment, { children: [jsxRuntime.jsx(react$1.Handle, { id: entry.id, type: "source", position: react$1.Position.Right, isConnectable: isConnectable, className: cx("!w-3 !h-3 !bg-white !dark:bg-stone-900 !border-gray-500 dark:!border-gray-400 !rounded-none", hasAny && (hasErr ? "!border-red-500" : "!border-amber-500")), style: { right: -5, top: topFor(i) } }), jsxRuntime.jsxs("div", { className: "absolute right-2 text-[11px] text-gray-700 dark:text-gray-300 pointer-events-none", style: {
1832
- top: topFor(i) - 8,
1833
- textAlign: "right",
1834
- left: "50%",
1835
- whiteSpace: "nowrap",
1836
- overflow: "hidden",
1837
- textOverflow: "ellipsis",
1838
- }, title: `${entry.id}: ${entry.typeId}`, children: [entry.id, hasAny && (jsxRuntime.jsx(IssueBadge, { level: hasErr ? "error" : "warning", size: 12, className: "ml-1", title: title })), showValues && (jsxRuntime.jsx("span", { className: "ml-1 opacity-60", children: toString(resolved.typeId, resolved.value) }))] })] }, `out-${entry.id}`));
1839
- })] }));
2021
+ .join("; ") })), jsxRuntime.jsxs("span", { className: "text-[10px] opacity-70", children: ["(", id, ")"] })] })] }), jsxRuntime.jsx("div", { className: cx("h-px", (isRunning || pct > 0) && "bg-blue-200 dark:bg-blue-900"), children: jsxRuntime.jsx("div", { className: cx("h-px transition-all", (isRunning || pct > 0) && "bg-blue-500"), style: { width: isRunning || pct > 0 ? `${pct}%` : 0 } }) }), jsxRuntime.jsx(NodeHandles, { data: data, isConnectable: isConnectable, getClassName: ({ kind, id }) => {
2022
+ const vIssues = (kind === "input" ? validation.inputs : validation.outputs).filter((v) => v.handle === id);
2023
+ const hasAny = vIssues.length > 0;
2024
+ const hasErr = vIssues.some((v) => v.level === "error");
2025
+ return cx("!w-3 !h-3 !bg-white !dark:bg-stone-900 !border-gray-500 dark:!border-gray-400", kind === "output" && "!rounded-none", hasAny && (hasErr ? "!border-red-500" : "!border-amber-500"));
2026
+ }, renderLabel: ({ kind, id }) => {
2027
+ const entries = kind === "input" ? inputEntries : outputEntries;
2028
+ const entry = entries.find((e) => e.id === id);
2029
+ if (!entry)
2030
+ return id;
2031
+ const vIssues = (kind === "input" ? validation.inputs : validation.outputs).filter((v) => v.handle === id);
2032
+ const hasAny = vIssues.length > 0;
2033
+ const hasErr = vIssues.some((v) => v.level === "error");
2034
+ const title = vIssues
2035
+ .map((v) => `${v.code}: ${v.message}`)
2036
+ .join("; ");
2037
+ // Compose label with truncated value to prevent layout growth
2038
+ const valueText = (() => {
2039
+ if (!showValues)
2040
+ return undefined;
2041
+ if (kind === "input") {
2042
+ const txt = toString(entry.typeId, inputValues?.[entry.id]);
2043
+ return typeof txt === "string" ? txt : String(txt);
2044
+ }
2045
+ const resolved = resolveOutputDisplay(outputValues?.[entry.id], entry.typeId);
2046
+ const txt = toString(resolved.typeId, resolved.value);
2047
+ return typeof txt === "string" ? txt : String(txt);
2048
+ })();
2049
+ return (jsxRuntime.jsxs("span", { className: "flex items-center gap-1 w-full", children: [kind === "output" ? (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [valueText !== undefined && (jsxRuntime.jsx("span", { className: "opacity-60 truncate pl-1", style: { flex: 1, minWidth: 0, maxWidth: "100%" }, children: valueText })), jsxRuntime.jsx("span", { className: "truncate shrink-0", style: { maxWidth: "40%" }, children: id })] })) : (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx("span", { className: "truncate shrink-0", style: { maxWidth: "40%" }, children: id }), valueText !== undefined && (jsxRuntime.jsx("span", { className: "opacity-60 truncate pr-1", style: { flex: 1, minWidth: 0, maxWidth: "100%" }, children: valueText }))] })), hasAny && (jsxRuntime.jsx(IssueBadge, { level: hasErr ? "error" : "warning", size: 12, className: "shrink-0", title: title }))] }));
2050
+ } })] }));
1840
2051
  });
1841
2052
  DefaultNode.displayName = "DefaultNode";
1842
2053
 
1843
2054
  function DefaultContextMenu({ open, clientPos, onAdd, onClose, }) {
1844
2055
  const { registry } = useWorkbenchContext();
1845
- const rf = react$1.useReactFlow();
2056
+ const rf = react.useReactFlow();
1846
2057
  const ids = Array.from(registry.nodes.keys());
1847
2058
  const [query, setQuery] = React.useState("");
1848
2059
  const q = query.trim().toLowerCase();
@@ -2001,6 +2212,58 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement }, r
2001
2212
  const { wb, registry, inputsMap, outputsMap, valuesTick, nodeStatus, edgeStatus, validationByNode, validationByEdge, } = useWorkbenchContext();
2002
2213
  const nodeValidation = validationByNode;
2003
2214
  const edgeValidation = validationByEdge.errors;
2215
+ // Keep stable references for nodes/edges to avoid unnecessary updates
2216
+ const prevNodesRef = React.useRef([]);
2217
+ const prevEdgesRef = React.useRef([]);
2218
+ function retainStabilityById(prev, next, isSame) {
2219
+ if (prev.length === 0)
2220
+ return next;
2221
+ const map = new Map();
2222
+ for (const p of prev)
2223
+ map.set(p.id, p);
2224
+ const out = new Array(next.length);
2225
+ for (let i = 0; i < next.length; i++) {
2226
+ const n = next[i];
2227
+ const p = map.get(n.id);
2228
+ out[i] = p && isSame(p, n) ? p : n;
2229
+ }
2230
+ return out;
2231
+ }
2232
+ const isSameNode = (a, b) => {
2233
+ // Compare the parts that affect rendering
2234
+ const pick = (n) => ({
2235
+ position: n.position,
2236
+ type: n.type,
2237
+ selected: n.selected,
2238
+ initialWidth: n.initialWidth,
2239
+ initialHeight: n.initialHeight,
2240
+ data: n.data && {
2241
+ typeId: n.data.typeId,
2242
+ inputHandles: n.data.inputHandles,
2243
+ outputHandles: n.data.outputHandles,
2244
+ showValues: n.data.showValues,
2245
+ inputValues: n.data.inputValues,
2246
+ outputValues: n.data.outputValues,
2247
+ status: n.data.status,
2248
+ validation: n.data.validation,
2249
+ },
2250
+ });
2251
+ return isEqual(pick(a), pick(b));
2252
+ };
2253
+ const isSameEdge = (a, b) => {
2254
+ const pick = (e) => ({
2255
+ source: e.source,
2256
+ target: e.target,
2257
+ sourceHandle: e.sourceHandle,
2258
+ targetHandle: e.targetHandle,
2259
+ selected: e.selected,
2260
+ animated: e.animated,
2261
+ style: e.style,
2262
+ label: e.label,
2263
+ type: e.type,
2264
+ });
2265
+ return isEqual(pick(a), pick(b));
2266
+ };
2004
2267
  // Expose imperative API
2005
2268
  const rfInstanceRef = React.useRef(null);
2006
2269
  React.useImperativeHandle(ref, () => ({
@@ -2011,7 +2274,7 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement }, r
2011
2274
  catch { }
2012
2275
  },
2013
2276
  }));
2014
- const { onConnect, onNodesChange, onEdgesChange, onEdgesDelete, onNodesDelete, onSelectionChange, } = useWorkbenchBridge(wb);
2277
+ const { onConnect, onNodesChange, onEdgesChange, onEdgesDelete, onNodesDelete, } = useWorkbenchBridge(wb);
2015
2278
  const { nodeTypes, resolveNodeType } = React.useMemo(() => {
2016
2279
  // Build nodeTypes map using UI extension registry
2017
2280
  const ui = wb.getUI();
@@ -2049,8 +2312,91 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement }, r
2049
2312
  selectedNodeIds: new Set(sel.nodes),
2050
2313
  selectedEdgeIds: new Set(sel.edges),
2051
2314
  });
2052
- // console.info(nodeTypes, out);
2053
- return out;
2315
+ // Retain references for unchanged items
2316
+ const stableNodes = retainStabilityById(prevNodesRef.current, out.nodes, isSameNode);
2317
+ const stableEdges = retainStabilityById(prevEdgesRef.current, out.edges, isSameEdge);
2318
+ // Debug: log updates/additions/removals (use value equality, not reference)
2319
+ try {
2320
+ const prevNodeIds = new Set(prevNodesRef.current.map((n) => n.id));
2321
+ const nextNodeIds = new Set(out.nodes.map((n) => n.id));
2322
+ const addedNodeIds = out.nodes
2323
+ .filter((n) => !prevNodeIds.has(n.id))
2324
+ .map((n) => n.id);
2325
+ const removedNodeIds = prevNodesRef.current
2326
+ .filter((n) => !nextNodeIds.has(n.id))
2327
+ .map((n) => n.id);
2328
+ const prevNodeMap = new Map(prevNodesRef.current.map((n) => [n.id, n]));
2329
+ const changedNodeIds = out.nodes
2330
+ .filter((n) => {
2331
+ const p = prevNodeMap.get(n.id);
2332
+ return p ? !isSameNode(p, n) : false;
2333
+ })
2334
+ .map((n) => n.id);
2335
+ // Detect handle updates (ids/length changes) for targeted debug
2336
+ const toIds = (arr) => Array.isArray(arr) ? arr.map((h) => h?.id) : [];
2337
+ const handlesEqual = (a, b) => {
2338
+ const aIds = toIds(a);
2339
+ const bIds = toIds(b);
2340
+ if (aIds.length !== bIds.length)
2341
+ return false;
2342
+ for (let i = 0; i < aIds.length; i++) {
2343
+ if (aIds[i] !== bIds[i])
2344
+ return false;
2345
+ }
2346
+ return true;
2347
+ };
2348
+ const handleChanged = out.nodes
2349
+ .filter((n) => {
2350
+ const p = prevNodeMap.get(n.id);
2351
+ if (!p)
2352
+ return false;
2353
+ const inChanged = !handlesEqual(p.data?.inputHandles, n.data?.inputHandles);
2354
+ const outChanged = !handlesEqual(p.data?.outputHandles, n.data?.outputHandles);
2355
+ return inChanged || outChanged;
2356
+ })
2357
+ .map((n) => n.id);
2358
+ const prevEdgeIds = new Set(prevEdgesRef.current.map((e) => e.id));
2359
+ const nextEdgeIds = new Set(out.edges.map((e) => e.id));
2360
+ const addedEdgeIds = out.edges
2361
+ .filter((e) => !prevEdgeIds.has(e.id))
2362
+ .map((e) => e.id);
2363
+ const removedEdgeIds = prevEdgesRef.current
2364
+ .filter((e) => !nextEdgeIds.has(e.id))
2365
+ .map((e) => e.id);
2366
+ const prevEdgeMap = new Map(prevEdgesRef.current.map((e) => [e.id, e]));
2367
+ const changedEdgeIds = out.edges
2368
+ .filter((e) => {
2369
+ const p = prevEdgeMap.get(e.id);
2370
+ return p ? !isSameEdge(p, e) : false;
2371
+ })
2372
+ .map((e) => e.id);
2373
+ if (addedNodeIds.length ||
2374
+ removedNodeIds.length ||
2375
+ changedNodeIds.length ||
2376
+ handleChanged.length) {
2377
+ // eslint-disable-next-line no-console
2378
+ console.debug("[WorkbenchCanvas] node updates", {
2379
+ added: addedNodeIds,
2380
+ removed: removedNodeIds,
2381
+ changed: changedNodeIds,
2382
+ handleChanged,
2383
+ });
2384
+ }
2385
+ if (addedEdgeIds.length ||
2386
+ removedEdgeIds.length ||
2387
+ changedEdgeIds.length) {
2388
+ // eslint-disable-next-line no-console
2389
+ console.debug("[WorkbenchCanvas] edge updates", {
2390
+ added: addedEdgeIds,
2391
+ removed: removedEdgeIds,
2392
+ changed: changedEdgeIds,
2393
+ });
2394
+ }
2395
+ }
2396
+ catch { }
2397
+ prevNodesRef.current = stableNodes;
2398
+ prevEdgesRef.current = stableEdges;
2399
+ return { nodes: stableNodes, edges: stableEdges };
2054
2400
  }, [
2055
2401
  showValues,
2056
2402
  inputsMap,
@@ -2062,9 +2408,9 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement }, r
2062
2408
  edgeStatus,
2063
2409
  nodeValidation,
2064
2410
  edgeValidation,
2065
- nodeTypes,
2066
2411
  resolveNodeType,
2067
2412
  ]);
2413
+ const throttled = useThrottledValue({ nodes, edges }, 100);
2068
2414
  const [menuOpen, setMenuOpen] = React.useState(false);
2069
2415
  const [menuPos, setMenuPos] = React.useState(null);
2070
2416
  const [nodeMenuOpen, setNodeMenuOpen] = React.useState(false);
@@ -2091,7 +2437,7 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement }, r
2091
2437
  const addNodeAt = (typeId, pos) => {
2092
2438
  wb.addNode({ typeId, position: pos });
2093
2439
  };
2094
- return (jsxRuntime.jsx("div", { className: "w-full h-full", onContextMenu: onContextMenu, children: jsxRuntime.jsxs(react$1.ReactFlow, { nodes: nodes, edges: edges, nodeTypes: nodeTypes, selectionOnDrag: true, onConnect: onConnect, onEdgesChange: onEdgesChange, onEdgesDelete: onEdgesDelete, onNodesDelete: onNodesDelete, onNodesChange: onNodesChange, onSelectionChange: onSelectionChange, deleteKeyCode: ["Backspace", "Delete"], fitView: true, onInit: (inst) => (rfInstanceRef.current = inst), children: [jsxRuntime.jsx(react$1.Background, {}), jsxRuntime.jsx(react$1.MiniMap, {}), jsxRuntime.jsx(react$1.Controls, {}), jsxRuntime.jsx(DefaultContextMenu, { open: menuOpen, clientPos: menuPos, onAdd: addNodeAt, onClose: () => setMenuOpen(false) }), jsxRuntime.jsx(NodeContextMenu, { open: nodeMenuOpen, clientPos: nodeMenuPos, nodeId: nodeAtMenu, onClose: () => setNodeMenuOpen(false) })] }) }));
2440
+ return (jsxRuntime.jsx("div", { className: "w-full h-full", onContextMenu: onContextMenu, children: jsxRuntime.jsxs(react.ReactFlow, { nodes: throttled.nodes, edges: throttled.edges, nodeTypes: nodeTypes, onlyRenderVisibleElements: true, selectionOnDrag: true, onConnect: onConnect, onEdgesChange: onEdgesChange, onEdgesDelete: onEdgesDelete, onNodesDelete: onNodesDelete, onNodesChange: onNodesChange, deleteKeyCode: ["Backspace", "Delete"], fitView: true, onInit: (inst) => (rfInstanceRef.current = inst), children: [jsxRuntime.jsx(react.Background, {}), jsxRuntime.jsx(react.MiniMap, {}), jsxRuntime.jsx(react.Controls, {}), jsxRuntime.jsx(DefaultContextMenu, { open: menuOpen, clientPos: menuPos, onAdd: addNodeAt, onClose: () => setMenuOpen(false) }), jsxRuntime.jsx(NodeContextMenu, { open: nodeMenuOpen, clientPos: nodeMenuPos, nodeId: nodeAtMenu, onClose: () => setNodeMenuOpen(false) })] }) }));
2095
2441
  });
2096
2442
 
2097
2443
  function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, example, onExampleChange, engine, onEngineChange, backendKind, onBackendKindChange, httpBaseUrl, onHttpBaseUrlChange, wsUrl, onWsUrlChange, debug, onDebugChange, showValues, onShowValuesChange, hideWorkbench, onHideWorkbenchChange, overrides, onInit, onChange, }) {
@@ -2203,9 +2549,14 @@ function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, ex
2203
2549
  if (!ex)
2204
2550
  return;
2205
2551
  const { registry: r, def } = await ex.load();
2206
- if (r) {
2207
- setRegistry(r);
2208
- wb.setRegistry(r);
2552
+ // Keep registry consistent with backend:
2553
+ // - For local backend, allow example to provide its own registry
2554
+ // - For remote backend, NEVER overwrite the hydrated remote registry
2555
+ if (backendKind === "local") {
2556
+ if (r) {
2557
+ setRegistry(r);
2558
+ wb.setRegistry(r);
2559
+ }
2209
2560
  }
2210
2561
  await wb.load(def);
2211
2562
  // Build a local runtime so seeded defaults are visible pre-run
@@ -2213,7 +2564,15 @@ function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, ex
2213
2564
  runAutoLayout();
2214
2565
  setExampleState(key);
2215
2566
  onExampleChange?.(key);
2216
- }, [runner, wb, onExampleChange, runAutoLayout, examples, setRegistry]);
2567
+ }, [
2568
+ runner,
2569
+ wb,
2570
+ onExampleChange,
2571
+ runAutoLayout,
2572
+ examples,
2573
+ setRegistry,
2574
+ backendKind,
2575
+ ]);
2217
2576
  const downloadGraph = React.useCallback(() => {
2218
2577
  try {
2219
2578
  const def = wb.export();
@@ -2515,11 +2874,11 @@ function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, ex
2515
2874
  return overrides.toElement(baseToElement, { registry });
2516
2875
  return baseToElement;
2517
2876
  }, [overrides, baseToElement, registry]);
2518
- return (jsxRuntime.jsxs("div", { className: "w-full h-screen flex flex-col", children: [jsxRuntime.jsxs("div", { className: "p-2 border-b border-gray-300 flex gap-2 items-center", children: [runner.isRunning() ? (jsxRuntime.jsxs("span", { className: "ml-2 text-sm text-green-700", children: ["Running: ", runner.getRunningEngine()] })) : (jsxRuntime.jsx("span", { className: "ml-2 text-sm text-gray-500", children: "Stopped" })), jsxRuntime.jsxs("span", { className: "ml-2 flex items-center gap-1 text-xs", title: transportStatus.kind || undefined, children: [transportStatus.state === "local" && (jsxRuntime.jsx(react.PlugsConnectedIcon, { size: 14, className: "text-gray-500" })), transportStatus.state === "connecting" && (jsxRuntime.jsx(react.ClockClockwiseIcon, { size: 14, className: "text-amber-600 animate-pulse" })), transportStatus.state === "connected" && (jsxRuntime.jsx(react.WifiHighIcon, { size: 14, className: "text-green-600" })), transportStatus.state === "disconnected" && (jsxRuntime.jsx(react.WifiSlashIcon, { size: 14, className: "text-red-600" })), transportStatus.state === "retrying" && (jsxRuntime.jsx(react.ClockClockwiseIcon, { size: 14, className: "text-amber-700 animate-pulse" }))] }), jsxRuntime.jsx("label", { className: "ml-2 text-sm", children: "Example:" }), jsxRuntime.jsxs("select", { className: "border border-gray-300 rounded px-2 py-1", value: exampleState, onChange: (e) => applyExample(e.target.value), disabled: runner.isRunning(), title: runner.isRunning()
2877
+ return (jsxRuntime.jsxs("div", { className: "w-full h-screen flex flex-col", children: [jsxRuntime.jsxs("div", { className: "p-2 border-b border-gray-300 flex gap-2 items-center", children: [runner.isRunning() ? (jsxRuntime.jsxs("span", { className: "ml-2 text-sm text-green-700", children: ["Running: ", runner.getRunningEngine()] })) : (jsxRuntime.jsx("span", { className: "ml-2 text-sm text-gray-500", children: "Stopped" })), jsxRuntime.jsxs("span", { className: "ml-2 flex items-center gap-1 text-xs", title: transportStatus.kind || undefined, children: [transportStatus.state === "local" && (jsxRuntime.jsx(react$1.PlugsConnectedIcon, { size: 14, className: "text-gray-500" })), transportStatus.state === "connecting" && (jsxRuntime.jsx(react$1.ClockClockwiseIcon, { size: 14, className: "text-amber-600 animate-pulse" })), transportStatus.state === "connected" && (jsxRuntime.jsx(react$1.WifiHighIcon, { size: 14, className: "text-green-600" })), transportStatus.state === "disconnected" && (jsxRuntime.jsx(react$1.WifiSlashIcon, { size: 14, className: "text-red-600" })), transportStatus.state === "retrying" && (jsxRuntime.jsx(react$1.ClockClockwiseIcon, { size: 14, className: "text-amber-700 animate-pulse" }))] }), jsxRuntime.jsx("label", { className: "ml-2 text-sm", children: "Example:" }), jsxRuntime.jsxs("select", { className: "border border-gray-300 rounded px-2 py-1", value: exampleState, onChange: (e) => applyExample(e.target.value), disabled: runner.isRunning(), title: runner.isRunning()
2519
2878
  ? "Stop engine before switching example"
2520
2879
  : undefined, children: [jsxRuntime.jsx("option", { value: "", children: "Select Example\u2026" }), examples.map((ex) => (jsxRuntime.jsx("option", { value: ex.id, children: ex.label }, ex.id)))] }), jsxRuntime.jsx("label", { className: "ml-2 text-sm", children: "Backend:" }), jsxRuntime.jsxs("select", { className: "border border-gray-300 rounded px-2 py-1", value: backendKind, onChange: (e) => onBackendKindChange(e.target.value), disabled: runner.isRunning(), title: runner.isRunning()
2521
2880
  ? "Stop engine before switching backend"
2522
- : undefined, children: [jsxRuntime.jsx("option", { value: "local", children: "Local" }), jsxRuntime.jsx("option", { value: "remote-http", children: "Remote (HTTP)" }), jsxRuntime.jsx("option", { value: "remote-ws", children: "Remote (WebSocket)" })] }), backendKind === "remote-http" && (jsxRuntime.jsx("input", { className: "ml-2 border border-gray-300 rounded px-2 py-1 w-72", placeholder: "http://127.0.0.1:18080", value: httpBaseUrl, onChange: (e) => onHttpBaseUrlChange(e.target.value) })), backendKind === "remote-ws" && (jsxRuntime.jsx("input", { className: "ml-2 border border-gray-300 rounded px-2 py-1 w-72", placeholder: "ws://127.0.0.1:18081", value: wsUrl, onChange: (e) => onWsUrlChange(e.target.value) })), jsxRuntime.jsxs("select", { className: "border border-gray-300 rounded px-2 py-1", value: runner.getRunningEngine() ?? engine ?? "", onChange: (e) => {
2881
+ : undefined, children: [jsxRuntime.jsx("option", { value: "local", children: "Local" }), jsxRuntime.jsx("option", { value: "remote-http", children: "Remote (HTTP)" }), jsxRuntime.jsx("option", { value: "remote-ws", children: "Remote (WebSocket)" })] }), backendKind === "remote-http" && !!onHttpBaseUrlChange && (jsxRuntime.jsx("input", { className: "ml-2 border border-gray-300 rounded px-2 py-1 w-72", placeholder: "http://127.0.0.1:18080", value: httpBaseUrl, onChange: (e) => onHttpBaseUrlChange(e.target.value) })), backendKind === "remote-ws" && !!onWsUrlChange && (jsxRuntime.jsx("input", { className: "ml-2 border border-gray-300 rounded px-2 py-1 w-72", placeholder: "ws://127.0.0.1:18081", value: wsUrl, onChange: (e) => onWsUrlChange(e.target.value) })), jsxRuntime.jsxs("select", { className: "border border-gray-300 rounded px-2 py-1", value: runner.getRunningEngine() ?? engine ?? "", onChange: (e) => {
2523
2882
  const kind = e.target.value || undefined;
2524
2883
  onEngineChange?.(kind);
2525
2884
  }, children: [jsxRuntime.jsx("option", { value: "", children: "Select Engine\u2026" }), jsxRuntime.jsx("option", { value: "push", children: "Push" }), jsxRuntime.jsx("option", { value: "batched", children: "Batched" }), jsxRuntime.jsx("option", { value: "pull", children: "Pull" }), jsxRuntime.jsx("option", { value: "hybrid", children: "Hybrid" }), jsxRuntime.jsx("option", { value: "step", children: "Step" })] }), runner.getRunningEngine() === "step" && (jsxRuntime.jsx("button", { className: "ml-2 border border-gray-300 rounded px-2 py-1.5", onClick: () => runner.step(), disabled: !runner.isRunning(), children: "Step" })), runner.getRunningEngine() === "batched" && (jsxRuntime.jsx("button", { className: "ml-2 border border-gray-300 rounded px-2 py-1.5", onClick: () => runner.flush(), disabled: !runner.isRunning(), children: "Flush" })), runner.isRunning() ? (jsxRuntime.jsx("button", { className: "border border-gray-300 rounded px-2 py-1.5", onClick: () => runner.dispose(), disabled: !runner.isRunning(), children: "Stop" })) : (jsxRuntime.jsx("button", { className: "border border-gray-300 rounded px-2 py-1.5", onClick: () => {
@@ -2568,6 +2927,7 @@ exports.DefaultUIExtensionRegistry = DefaultUIExtensionRegistry;
2568
2927
  exports.InMemoryWorkbench = InMemoryWorkbench;
2569
2928
  exports.Inspector = Inspector;
2570
2929
  exports.LocalGraphRunner = LocalGraphRunner;
2930
+ exports.NodeHandles = NodeHandles;
2571
2931
  exports.RemoteGraphRunner = RemoteGraphRunner;
2572
2932
  exports.WorkbenchCanvas = WorkbenchCanvas;
2573
2933
  exports.WorkbenchContext = WorkbenchContext;
@@ -2582,6 +2942,7 @@ exports.summarizeDeep = summarizeDeep;
2582
2942
  exports.toReactFlow = toReactFlow;
2583
2943
  exports.useQueryParamBoolean = useQueryParamBoolean;
2584
2944
  exports.useQueryParamString = useQueryParamString;
2945
+ exports.useThrottledValue = useThrottledValue;
2585
2946
  exports.useWorkbenchBridge = useWorkbenchBridge;
2586
2947
  exports.useWorkbenchContext = useWorkbenchContext;
2587
2948
  exports.useWorkbenchGraphTick = useWorkbenchGraphTick;