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