@bian-womp/spark-workbench 0.1.30 → 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 +484 -124
  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 +3 -2
  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 +3 -4
  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 +23 -1
  22. package/lib/cjs/src/misc/mapping.d.ts.map +1 -1
  23. package/lib/esm/index.js +481 -123
  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 +3 -2
  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 +3 -4
  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 +23 -1
  44. package/lib/esm/src/misc/mapping.d.ts.map +1 -1
  45. package/package.json +10 -8
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 ReactFlow = require('reactflow');
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,83 +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
1818
- .map((v) => `${v.code}: ${v.message}`)
1819
- .join("; ");
1820
- return (jsxRuntime.jsxs(React.Fragment, { children: [jsxRuntime.jsx(ReactFlow.Handle, { id: entry.id, type: "target", position: ReactFlow.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: {
1821
- top: topFor(i) - 8,
1822
- right: "50%",
1823
- whiteSpace: "nowrap",
1824
- overflow: "hidden",
1825
- textOverflow: "ellipsis",
1826
- }, 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}`));
1827
- }), outputEntries.map((entry, i) => {
1828
- const vIssues = validation.outputs.filter((v) => v.handle === entry.id);
1829
- const hasAny = vIssues.length > 0;
1830
- const hasErr = vIssues.some((v) => v.level === "error");
1831
- const title = vIssues
1832
- .map((v) => `${v.code}: ${v.message}`)
1833
- .join("; ");
1834
- const resolved = resolveOutputDisplay(outputValues?.[entry.id], entry.typeId);
1835
- return (jsxRuntime.jsxs(React.Fragment, { children: [jsxRuntime.jsx(ReactFlow.Handle, { id: entry.id, type: "source", position: ReactFlow.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: {
1836
- top: topFor(i) - 8,
1837
- textAlign: "right",
1838
- left: "50%",
1839
- whiteSpace: "nowrap",
1840
- overflow: "hidden",
1841
- textOverflow: "ellipsis",
1842
- }, 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}`));
1843
- })] }));
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
+ } })] }));
1844
2051
  });
1845
2052
  DefaultNode.displayName = "DefaultNode";
1846
2053
 
1847
2054
  function DefaultContextMenu({ open, clientPos, onAdd, onClose, }) {
1848
2055
  const { registry } = useWorkbenchContext();
1849
- const rf = ReactFlow.useReactFlow();
2056
+ const rf = react.useReactFlow();
1850
2057
  const ids = Array.from(registry.nodes.keys());
1851
2058
  const [query, setQuery] = React.useState("");
1852
2059
  const q = query.trim().toLowerCase();
@@ -2005,6 +2212,58 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement }, r
2005
2212
  const { wb, registry, inputsMap, outputsMap, valuesTick, nodeStatus, edgeStatus, validationByNode, validationByEdge, } = useWorkbenchContext();
2006
2213
  const nodeValidation = validationByNode;
2007
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
+ };
2008
2267
  // Expose imperative API
2009
2268
  const rfInstanceRef = React.useRef(null);
2010
2269
  React.useImperativeHandle(ref, () => ({
@@ -2015,7 +2274,7 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement }, r
2015
2274
  catch { }
2016
2275
  },
2017
2276
  }));
2018
- const { onConnect, onNodesChange, onEdgesChange, onEdgesDelete, onNodesDelete, onSelectionChange, } = useWorkbenchBridge(wb);
2277
+ const { onConnect, onNodesChange, onEdgesChange, onEdgesDelete, onNodesDelete, } = useWorkbenchBridge(wb);
2019
2278
  const { nodeTypes, resolveNodeType } = React.useMemo(() => {
2020
2279
  // Build nodeTypes map using UI extension registry
2021
2280
  const ui = wb.getUI();
@@ -2053,8 +2312,91 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement }, r
2053
2312
  selectedNodeIds: new Set(sel.nodes),
2054
2313
  selectedEdgeIds: new Set(sel.edges),
2055
2314
  });
2056
- console.info(nodeTypes, out);
2057
- 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 };
2058
2400
  }, [
2059
2401
  showValues,
2060
2402
  inputsMap,
@@ -2066,9 +2408,9 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement }, r
2066
2408
  edgeStatus,
2067
2409
  nodeValidation,
2068
2410
  edgeValidation,
2069
- nodeTypes,
2070
2411
  resolveNodeType,
2071
2412
  ]);
2413
+ const throttled = useThrottledValue({ nodes, edges }, 100);
2072
2414
  const [menuOpen, setMenuOpen] = React.useState(false);
2073
2415
  const [menuPos, setMenuPos] = React.useState(null);
2074
2416
  const [nodeMenuOpen, setNodeMenuOpen] = React.useState(false);
@@ -2095,7 +2437,7 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement }, r
2095
2437
  const addNodeAt = (typeId, pos) => {
2096
2438
  wb.addNode({ typeId, position: pos });
2097
2439
  };
2098
- return (jsxRuntime.jsx("div", { className: "w-full h-full", onContextMenu: onContextMenu, children: jsxRuntime.jsxs(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(ReactFlow.Background, {}), jsxRuntime.jsx(ReactFlow.MiniMap, {}), jsxRuntime.jsx(ReactFlow.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) })] }) }));
2099
2441
  });
2100
2442
 
2101
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, }) {
@@ -2172,7 +2514,7 @@ function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, ex
2172
2514
  runAutoLayout();
2173
2515
  };
2174
2516
  onInit({ wb, runner, setInitialGraph });
2175
- }, [onInit, wb, runner, runAutoLayout]);
2517
+ }, [onInit, wb, runner, runAutoLayout, registry, setRegistry]);
2176
2518
  // Expose change callback on graph/value changes
2177
2519
  React.useEffect(() => {
2178
2520
  if (!onChange)
@@ -2207,9 +2549,14 @@ function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, ex
2207
2549
  if (!ex)
2208
2550
  return;
2209
2551
  const { registry: r, def } = await ex.load();
2210
- if (r) {
2211
- setRegistry(r);
2212
- 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
+ }
2213
2560
  }
2214
2561
  await wb.load(def);
2215
2562
  // Build a local runtime so seeded defaults are visible pre-run
@@ -2217,7 +2564,15 @@ function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, ex
2217
2564
  runAutoLayout();
2218
2565
  setExampleState(key);
2219
2566
  onExampleChange?.(key);
2220
- }, [runner, wb, onExampleChange, runAutoLayout, examples, setRegistry]);
2567
+ }, [
2568
+ runner,
2569
+ wb,
2570
+ onExampleChange,
2571
+ runAutoLayout,
2572
+ examples,
2573
+ setRegistry,
2574
+ backendKind,
2575
+ ]);
2221
2576
  const downloadGraph = React.useCallback(() => {
2222
2577
  try {
2223
2578
  const def = wb.export();
@@ -2327,6 +2682,9 @@ function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, ex
2327
2682
  return;
2328
2683
  if (runner.isRunning())
2329
2684
  return;
2685
+ // Only auto-launch for local backend; require explicit Start for remote
2686
+ if (backendKind !== "local")
2687
+ return;
2330
2688
  const d = wb.export();
2331
2689
  if (!d.nodes || d.nodes.length === 0)
2332
2690
  return;
@@ -2339,14 +2697,14 @@ function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, ex
2339
2697
  catch {
2340
2698
  // ignore
2341
2699
  }
2342
- }, [engine, runner, wb]);
2700
+ }, [engine, runner, wb, backendKind]);
2343
2701
  // When switching to remote backend, auto-hydrate registry from backend
2344
2702
  React.useEffect(() => {
2345
2703
  if (backendKind === "remote-http" && httpBaseUrl) {
2346
- void hydrateFromBackend("remote-http", httpBaseUrl);
2704
+ hydrateFromBackend("remote-http", httpBaseUrl);
2347
2705
  }
2348
2706
  else if (backendKind === "remote-ws" && wsUrl) {
2349
- void hydrateFromBackend("remote-ws", wsUrl);
2707
+ hydrateFromBackend("remote-ws", wsUrl);
2350
2708
  }
2351
2709
  }, [backendKind, httpBaseUrl, wsUrl, hydrateFromBackend]);
2352
2710
  React.useEffect(() => {
@@ -2516,11 +2874,11 @@ function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, ex
2516
2874
  return overrides.toElement(baseToElement, { registry });
2517
2875
  return baseToElement;
2518
2876
  }, [overrides, baseToElement, registry]);
2519
- 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()
2520
2878
  ? "Stop engine before switching example"
2521
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()
2522
2880
  ? "Stop engine before switching backend"
2523
- : 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) => {
2524
2882
  const kind = e.target.value || undefined;
2525
2883
  onEngineChange?.(kind);
2526
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: () => {
@@ -2535,7 +2893,7 @@ function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, ex
2535
2893
  }
2536
2894
  }, disabled: !engine, children: "Start" })), jsxRuntime.jsx("button", { className: "border border-gray-300 rounded px-2 py-1.5", onClick: runAutoLayout, children: "Auto Layout" }), jsxRuntime.jsx("button", { className: "ml-2 border border-gray-300 rounded px-2 py-1.5", onClick: () => canvasRef.current?.fitView?.(), title: "Fit View", children: "Fit View" }), jsxRuntime.jsx("button", { className: "ml-2 border border-gray-300 rounded px-2 py-1.5", onClick: downloadGraph, children: "Download Graph" }), jsxRuntime.jsxs("label", { className: "ml-2 flex items-center gap-1", children: [jsxRuntime.jsx("input", { type: "checkbox", checked: debug, onChange: (e) => onDebugChange(e.target.checked) }), jsxRuntime.jsx("span", { children: "Debug events" })] }), jsxRuntime.jsxs("label", { className: "ml-2 flex items-center gap-1", children: [jsxRuntime.jsx("input", { type: "checkbox", checked: showValues, onChange: (e) => onShowValuesChange(e.target.checked) }), jsxRuntime.jsx("span", { children: "Show values in nodes" })] })] }), jsxRuntime.jsxs("div", { className: "flex flex-1 min-h-0", children: [jsxRuntime.jsx("div", { className: "flex-1 min-w-0", children: jsxRuntime.jsx(WorkbenchCanvas, { ref: canvasRef, showValues: showValues, toString: toString, toElement: toElement }) }), jsxRuntime.jsx(Inspector, { setInput: setInput, debug: debug, autoScroll: autoScroll, hideWorkbench: hideWorkbench, onAutoScrollChange: onAutoScrollChange, onHideWorkbenchChange: onHideWorkbenchChange, toString: toString, toElement: toElement, contextPanel: overrides?.contextPanel })] })] }));
2537
2895
  }
2538
- function WorkbenchStudio({ engine, onEngineChange, example, onExampleChange, backendKind, onBackendKindChange, httpBaseUrl, onHttpBaseUrlChange, wsUrl, onWsUrlChange, debug, onDebugChange, showValues, onShowValuesChange, hideWorkbench, onHideWorkbenchChange, autoScroll, onAutoScrollChange, overrides, onInit, onChange, contextPanel, }) {
2896
+ function WorkbenchStudio({ engine, onEngineChange, example, onExampleChange, backendKind, onBackendKindChange, httpBaseUrl, onHttpBaseUrlChange, wsUrl, onWsUrlChange, debug, onDebugChange, showValues, onShowValuesChange, hideWorkbench, onHideWorkbenchChange, autoScroll, onAutoScrollChange, overrides, onInit, onChange, }) {
2539
2897
  const [registry, setRegistry] = React.useState(sparkGraph.createSimpleGraphRegistry());
2540
2898
  const [wb] = React.useState(() => new InMemoryWorkbench({ ui: new DefaultUIExtensionRegistry() }));
2541
2899
  const runner = React.useMemo(() => {
@@ -2569,6 +2927,7 @@ exports.DefaultUIExtensionRegistry = DefaultUIExtensionRegistry;
2569
2927
  exports.InMemoryWorkbench = InMemoryWorkbench;
2570
2928
  exports.Inspector = Inspector;
2571
2929
  exports.LocalGraphRunner = LocalGraphRunner;
2930
+ exports.NodeHandles = NodeHandles;
2572
2931
  exports.RemoteGraphRunner = RemoteGraphRunner;
2573
2932
  exports.WorkbenchCanvas = WorkbenchCanvas;
2574
2933
  exports.WorkbenchContext = WorkbenchContext;
@@ -2583,6 +2942,7 @@ exports.summarizeDeep = summarizeDeep;
2583
2942
  exports.toReactFlow = toReactFlow;
2584
2943
  exports.useQueryParamBoolean = useQueryParamBoolean;
2585
2944
  exports.useQueryParamString = useQueryParamString;
2945
+ exports.useThrottledValue = useThrottledValue;
2586
2946
  exports.useWorkbenchBridge = useWorkbenchBridge;
2587
2947
  exports.useWorkbenchContext = useWorkbenchContext;
2588
2948
  exports.useWorkbenchGraphTick = useWorkbenchGraphTick;