@bian-womp/spark-workbench 0.1.10 → 0.1.11

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 (33) hide show
  1. package/lib/cjs/index.cjs +351 -155
  2. package/lib/cjs/index.cjs.map +1 -1
  3. package/lib/cjs/src/index.d.ts +1 -1
  4. package/lib/cjs/src/index.d.ts.map +1 -1
  5. package/lib/cjs/src/misc/DefaultContextMenu.d.ts.map +1 -1
  6. package/lib/cjs/src/misc/DefaultNode.d.ts.map +1 -1
  7. package/lib/cjs/src/misc/Inspector.d.ts.map +1 -1
  8. package/lib/cjs/src/misc/NodeContextMenu.d.ts +10 -0
  9. package/lib/cjs/src/misc/NodeContextMenu.d.ts.map +1 -0
  10. package/lib/cjs/src/misc/WorkbenchCanvas.d.ts +2 -2
  11. package/lib/cjs/src/misc/WorkbenchCanvas.d.ts.map +1 -1
  12. package/lib/cjs/src/misc/WorkbenchStudio.d.ts.map +1 -1
  13. package/lib/cjs/src/misc/context/WorkbenchContext.provider.d.ts.map +1 -1
  14. package/lib/cjs/src/misc/mapping.d.ts +35 -4
  15. package/lib/cjs/src/misc/mapping.d.ts.map +1 -1
  16. package/lib/cjs/src/runtime/GraphRunner.d.ts.map +1 -1
  17. package/lib/esm/index.js +350 -154
  18. package/lib/esm/index.js.map +1 -1
  19. package/lib/esm/src/index.d.ts +1 -1
  20. package/lib/esm/src/index.d.ts.map +1 -1
  21. package/lib/esm/src/misc/DefaultContextMenu.d.ts.map +1 -1
  22. package/lib/esm/src/misc/DefaultNode.d.ts.map +1 -1
  23. package/lib/esm/src/misc/Inspector.d.ts.map +1 -1
  24. package/lib/esm/src/misc/NodeContextMenu.d.ts +10 -0
  25. package/lib/esm/src/misc/NodeContextMenu.d.ts.map +1 -0
  26. package/lib/esm/src/misc/WorkbenchCanvas.d.ts +2 -2
  27. package/lib/esm/src/misc/WorkbenchCanvas.d.ts.map +1 -1
  28. package/lib/esm/src/misc/WorkbenchStudio.d.ts.map +1 -1
  29. package/lib/esm/src/misc/context/WorkbenchContext.provider.d.ts.map +1 -1
  30. package/lib/esm/src/misc/mapping.d.ts +35 -4
  31. package/lib/esm/src/misc/mapping.d.ts.map +1 -1
  32. package/lib/esm/src/runtime/GraphRunner.d.ts.map +1 -1
  33. package/package.json +4 -4
package/lib/cjs/index.cjs CHANGED
@@ -319,37 +319,6 @@ class CLIWorkbench {
319
319
  }
320
320
  }
321
321
 
322
- function toReactFlow$1(def, positions = {}) {
323
- const nodes = def.nodes.map((n) => ({
324
- id: n.nodeId,
325
- data: { typeId: n.typeId, params: n.params },
326
- position: positions[n.nodeId] ?? { x: 0, y: 0 },
327
- }));
328
- const edges = def.edges.map((e) => ({
329
- id: e.id,
330
- source: e.source.nodeId,
331
- target: e.target.nodeId,
332
- sourceHandle: e.source.handle,
333
- targetHandle: e.target.handle,
334
- }));
335
- return { nodes, edges };
336
- }
337
- class ReactFlowWorkbench {
338
- constructor(wb) {
339
- this.wb = wb;
340
- }
341
- get actions() {
342
- return this.wb;
343
- }
344
- async load(def) {
345
- await this.wb.load(def);
346
- }
347
- export(def) {
348
- const d = def ?? this.wb.export();
349
- return toReactFlow$1(d);
350
- }
351
- }
352
-
353
322
  class GraphRunner {
354
323
  constructor(registry, backend) {
355
324
  this.registry = registry;
@@ -363,6 +332,8 @@ class GraphRunner {
363
332
  if (this.backend.kind === "local") {
364
333
  const builder = new sparkGraph.GraphBuilder(this.registry);
365
334
  this.runtime = builder.build(def);
335
+ // Signal UI that freshly built graph should be considered invalidated
336
+ this.emit("invalidate", { reason: "graph-built" });
366
337
  return;
367
338
  }
368
339
  // Remote: no-op here; build is performed on remote server during launch
@@ -371,7 +342,20 @@ class GraphRunner {
371
342
  if (this.backend.kind === "local") {
372
343
  if (!this.runtime)
373
344
  return;
345
+ // Prevent mid-run churn while wiring changes are applied
346
+ try {
347
+ this.runtime.pause();
348
+ }
349
+ catch {
350
+ console.error("Failed to pause runtime");
351
+ }
374
352
  this.runtime.update(def, this.registry);
353
+ try {
354
+ this.runtime.resume();
355
+ }
356
+ catch {
357
+ console.error("Failed to resume runtime");
358
+ }
375
359
  this.emit("invalidate", { reason: "graph-updated" });
376
360
  return;
377
361
  }
@@ -434,6 +418,8 @@ class GraphRunner {
434
418
  // Remote: build remotely then launch
435
419
  void this.ensureRemote().then(async (rc) => {
436
420
  await rc.runner.build(def);
421
+ // Signal UI after remote build as well
422
+ this.emit("invalidate", { reason: "graph-built" });
437
423
  const eng = rc.runner.getEngine();
438
424
  if (!rc.listenersBound) {
439
425
  eng.on("value", (e) => {
@@ -463,8 +449,13 @@ class GraphRunner {
463
449
  if (!this.stagedInputs[nodeId])
464
450
  this.stagedInputs[nodeId] = {};
465
451
  this.stagedInputs[nodeId][handle] = value;
466
- if (this.engine)
452
+ if (this.engine) {
467
453
  this.engine.setInput(nodeId, handle, value);
454
+ }
455
+ else {
456
+ // Emit a value event so UI updates even when engine isn't running
457
+ this.emit("value", { nodeId, handle, value, io: "input" });
458
+ }
468
459
  }
469
460
  async step() {
470
461
  if (this.backend.kind !== "local")
@@ -780,6 +771,98 @@ function useQueryParamString(key, defaultValue) {
780
771
  return [val, set];
781
772
  }
782
773
 
774
+ function toReactFlow(def, positions, registry, opts) {
775
+ const nodeHandleMap = {};
776
+ const nodes = def.nodes.map((n) => {
777
+ const desc = registry.nodes.get(n.typeId);
778
+ const inputHandles = Object.entries(desc?.inputs ?? {}).map(([id, typeId]) => ({ id, typeId }));
779
+ const outputHandles = Object.entries(desc?.outputs ?? {}).map(([id, typeId]) => ({ id, typeId }));
780
+ nodeHandleMap[n.nodeId] = {
781
+ inputs: new Set(inputHandles.map((h) => h.id)),
782
+ outputs: new Set(outputHandles.map((h) => h.id)),
783
+ };
784
+ return {
785
+ id: n.nodeId,
786
+ data: {
787
+ typeId: n.typeId,
788
+ params: n.params,
789
+ inputHandles,
790
+ outputHandles,
791
+ showValues: opts.showValues,
792
+ inputValues: opts.inputs?.[n.nodeId],
793
+ outputValues: opts.outputs?.[n.nodeId],
794
+ status: opts.nodeStatus?.[n.nodeId],
795
+ validation: {
796
+ inputs: opts.nodeValidation?.inputs?.[n.nodeId] ?? [],
797
+ outputs: opts.nodeValidation?.outputs?.[n.nodeId] ?? [],
798
+ issues: opts.nodeValidation?.issues?.[n.nodeId] ?? [],
799
+ },
800
+ toString: opts.toString,
801
+ toElement: opts.toElement,
802
+ },
803
+ position: positions[n.nodeId] ?? { x: 0, y: 0 },
804
+ type: opts.resolveNodeType?.(n.typeId) ?? "spark-default",
805
+ selected: opts.selectedNodeIds
806
+ ? opts.selectedNodeIds.has(n.nodeId)
807
+ : undefined,
808
+ };
809
+ });
810
+ const edges = def.edges
811
+ .filter((e) => {
812
+ const src = nodeHandleMap[e.source.nodeId];
813
+ const dst = nodeHandleMap[e.target.nodeId];
814
+ if (!src || !dst)
815
+ return false;
816
+ return (src.outputs.has(e.source.handle) && dst.inputs.has(e.target.handle));
817
+ })
818
+ .map((e) => {
819
+ const st = opts.edgeStatus?.[e.id];
820
+ const isRunning = !!st?.running;
821
+ const hasError = !!st?.lastError;
822
+ const isInvalidEdge = !!opts.edgeValidation?.[e.id];
823
+ const style = hasError || isInvalidEdge
824
+ ? { stroke: "#ef4444", strokeWidth: 2 }
825
+ : isRunning
826
+ ? { stroke: "#3b82f6" }
827
+ : undefined;
828
+ return {
829
+ id: e.id,
830
+ source: e.source.nodeId,
831
+ target: e.target.nodeId,
832
+ sourceHandle: e.source.handle,
833
+ targetHandle: e.target.handle,
834
+ selected: opts.selectedEdgeIds
835
+ ? opts.selectedEdgeIds.has(e.id)
836
+ : undefined,
837
+ animated: isRunning,
838
+ style,
839
+ };
840
+ });
841
+ return { nodes, edges };
842
+ }
843
+ // Shared node container border class composition for consistent visuals
844
+ function getNodeBorderClassNames(args) {
845
+ const selected = !!args.selected;
846
+ const status = args.status || {};
847
+ const issues = args.validation?.issues ?? [];
848
+ const hasError = !!status.lastError;
849
+ const hasValidationError = issues.some((i) => i?.level === "error");
850
+ const hasValidationWarning = !hasValidationError && issues.length > 0;
851
+ const isRunning = !!status.running;
852
+ const isInvalid = !!status.invalidated && !isRunning && !hasError;
853
+ const borderWidth = selected ? "border-2" : "border";
854
+ const borderStyle = isInvalid ? "border-dashed" : "border-solid";
855
+ const borderColor = hasError || hasValidationError
856
+ ? "border-red-500"
857
+ : hasValidationWarning
858
+ ? "border-amber-500"
859
+ : isRunning
860
+ ? "border-blue-500"
861
+ : "border-gray-500 dark:border-gray-400";
862
+ const ring = isRunning ? " ring-2 ring-blue-200 dark:ring-blue-900" : "";
863
+ return `${borderWidth} ${borderStyle} ${borderColor}${ring}`.trim();
864
+ }
865
+
783
866
  const WorkbenchContext = React.createContext(null);
784
867
  function useWorkbenchContext() {
785
868
  const ctx = React.useContext(WorkbenchContext);
@@ -808,6 +891,19 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, children, }) {
808
891
  const def = wb.export();
809
892
  const inputsMap = React.useMemo(() => runner.getInputs(def), [runner, def, valuesTick]);
810
893
  const outputsMap = React.useMemo(() => runner.getOutputs(def), [runner, def, valuesTick]);
894
+ // Initialize nodes as invalidated by default until first successful run
895
+ React.useEffect(() => {
896
+ setNodeStatus((prev) => {
897
+ const next = { ...prev };
898
+ for (const n of def.nodes) {
899
+ const cur = next[n.nodeId] ?? (next[n.nodeId] = {});
900
+ if (cur.invalidated === undefined) {
901
+ next[n.nodeId] = { ...cur, invalidated: true };
902
+ }
903
+ }
904
+ return next;
905
+ });
906
+ }, [def]);
811
907
  // Auto layout (simple layered layout)
812
908
  const runAutoLayout = React.useCallback(() => {
813
909
  const cur = wb.export();
@@ -875,20 +971,20 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, children, }) {
875
971
  const off2 = runner.on("error", (e) => {
876
972
  const edgeError = e;
877
973
  const nodeError = e;
878
- if (edgeError?.kind === "edge-convert") {
974
+ if (edgeError.kind === "edge-convert") {
879
975
  const edgeId = edgeError.edgeId;
880
976
  setEdgeStatus((s) => ({
881
977
  ...s,
882
978
  [edgeId]: { ...(s[edgeId] ?? {}), lastError: edgeError.err },
883
979
  }));
884
980
  }
885
- else if (nodeError?.nodeId) {
886
- const nodeId = nodeError?.nodeId;
981
+ else if (nodeError.nodeId) {
982
+ const nodeId = nodeError.nodeId;
887
983
  setNodeStatus((s) => ({
888
984
  ...s,
889
985
  [nodeId]: {
890
986
  ...(s[nodeId] ?? {}),
891
- lastError: nodeError?.err,
987
+ lastError: nodeError.err,
892
988
  },
893
989
  }));
894
990
  }
@@ -956,6 +1052,17 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, children, }) {
956
1052
  return add("runner", "stats")(s);
957
1053
  });
958
1054
  const off4 = wb.on("graphChanged", add("workbench", "graphChanged"));
1055
+ // Ensure newly added nodes start as invalidated until first evaluation
1056
+ const off4c = wb.on("graphChanged", (e) => {
1057
+ const change = e.change;
1058
+ if (change?.type === "addNode" && typeof change.nodeId === "string") {
1059
+ const id = change.nodeId;
1060
+ setNodeStatus((s) => ({
1061
+ ...s,
1062
+ [id]: { ...(s[id] ?? {}), invalidated: true },
1063
+ }));
1064
+ }
1065
+ });
959
1066
  const off4b = wb.on("graphUiChanged", add("workbench", "graphUiChanged"));
960
1067
  const off5 = wb.on("validationChanged", add("workbench", "validationChanged"));
961
1068
  const off5b = wb.on("validationChanged", (r) => setValidation(r));
@@ -972,6 +1079,7 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, children, }) {
972
1079
  off3b();
973
1080
  off4();
974
1081
  off4b();
1082
+ off4c();
975
1083
  off5();
976
1084
  off5b();
977
1085
  off6();
@@ -994,10 +1102,10 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, children, }) {
994
1102
  if (!validation)
995
1103
  return { inputs, outputs, issues };
996
1104
  for (const is of validation.issues ?? []) {
997
- const d = is?.data;
998
- const level = is?.level;
999
- const code = String(is?.code ?? "");
1000
- const message = String(is?.message ?? code);
1105
+ const d = is.data;
1106
+ const level = is.level;
1107
+ const code = String(is.code ?? "");
1108
+ const message = String(is.message ?? code);
1001
1109
  if (!d)
1002
1110
  continue;
1003
1111
  if (d.nodeId) {
@@ -1026,10 +1134,10 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, children, }) {
1026
1134
  if (!validation)
1027
1135
  return list;
1028
1136
  for (const is of validation.issues ?? []) {
1029
- const d = is?.data;
1030
- const level = is?.level;
1031
- const code = String(is?.code ?? "");
1032
- const message = String(is?.message ?? code);
1137
+ const d = is.data;
1138
+ const level = is.level;
1139
+ const code = String(is.code ?? "");
1140
+ const message = String(is.message ?? code);
1033
1141
  if (!d || (!d.nodeId && !d.edgeId)) {
1034
1142
  list.push({ level, code, message });
1035
1143
  }
@@ -1042,10 +1150,10 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, children, }) {
1042
1150
  if (!validation)
1043
1151
  return { errors, issues };
1044
1152
  for (const is of validation.issues ?? []) {
1045
- const d = is?.data;
1046
- const level = is?.level;
1047
- const code = String(is?.code ?? "");
1048
- const message = String(is?.message ?? code);
1153
+ const d = is.data;
1154
+ const level = is.level;
1155
+ const code = String(is.code ?? "");
1156
+ const message = String(is.message ?? code);
1049
1157
  if (d?.edgeId) {
1050
1158
  if (level === "error")
1051
1159
  errors[d.edgeId] = true;
@@ -1156,6 +1264,16 @@ function DebugEvents({ autoScroll, onAutoScrollChange, hideWorkbench, onHideWork
1156
1264
  }
1157
1265
 
1158
1266
  function Inspector({ debug, autoScroll, hideWorkbench, onAutoScrollChange, onHideWorkbenchChange, toString, toElement, setInput, }) {
1267
+ const safeToString = (typeId, value) => {
1268
+ try {
1269
+ return typeof toString === "function"
1270
+ ? toString(typeId, value)
1271
+ : String(value ?? "");
1272
+ }
1273
+ catch {
1274
+ return String(value ?? "");
1275
+ }
1276
+ };
1159
1277
  const { registry, def, selectedNodeId, selectedEdgeId, inputsMap, outputsMap, nodeStatus, validationByNode, validationByEdge, validationGlobal, valuesTick, } = useWorkbenchContext();
1160
1278
  const nodeValidationIssues = validationByNode.issues;
1161
1279
  const edgeValidationIssues = validationByEdge.issues;
@@ -1215,7 +1333,7 @@ function Inspector({ debug, autoScroll, hideWorkbench, onAutoScrollChange, onHid
1215
1333
  for (const h of handles) {
1216
1334
  const typeId = desc?.inputs?.[h];
1217
1335
  const current = nodeInputs[h];
1218
- const display = toString(typeId, current);
1336
+ const display = safeToString(typeId, current);
1219
1337
  const wasOriginal = originals[h];
1220
1338
  const isDirty = drafts[h] !== undefined &&
1221
1339
  wasOriginal !== undefined &&
@@ -1241,7 +1359,7 @@ function Inspector({ debug, autoScroll, hideWorkbench, onAutoScrollChange, onHid
1241
1359
  disabled: isLinked,
1242
1360
  };
1243
1361
  const current = nodeInputs[h];
1244
- const value = drafts[h] ?? toString(typeId, current);
1362
+ const value = drafts[h] ?? safeToString(typeId, current);
1245
1363
  const onChangeText = (text) => setDrafts((d) => ({ ...d, [h]: text }));
1246
1364
  const commit = () => {
1247
1365
  const draft = drafts[h];
@@ -1251,7 +1369,7 @@ function Inspector({ debug, autoScroll, hideWorkbench, onAutoScrollChange, onHid
1251
1369
  setOriginals((o) => ({ ...o, [h]: draft }));
1252
1370
  };
1253
1371
  const revert = () => {
1254
- const orig = originals[h] ?? toString(typeId, current);
1372
+ const orig = originals[h] ?? safeToString(typeId, current);
1255
1373
  setDrafts((d) => ({ ...d, [h]: orig }));
1256
1374
  };
1257
1375
  const isEnum = typeId?.includes("enum:");
@@ -1261,19 +1379,17 @@ function Inspector({ debug, autoScroll, hideWorkbench, onAutoScrollChange, onHid
1261
1379
  const title = inIssues
1262
1380
  .map((v) => `${v.code}: ${v.message}`)
1263
1381
  .join("; ");
1264
- return (jsxRuntime.jsxs("div", { className: "flex items-center gap-2 mb-1", children: [jsxRuntime.jsxs("label", { className: "w-28", children: [h, jsxRuntime.jsx("span", { className: "text-gray-500 ml-1 text-[11px]", children: selectedDesc?.inputs?.[h] })] }), hasValidation && (jsxRuntime.jsx(IssueBadge, { level: hasErr ? "error" : "warning", size: 24, className: "ml-1 w-6 h-6", title: title })), isEnum ? (jsxRuntime.jsxs("select", { className: "border border-gray-300 rounded px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500 w-full", value: drafts[h] ?? toString(typeId, current), onChange: (e) => {
1265
- const label = String(e.target.value);
1266
- const byLabel = registry.enums
1267
- .get(typeId)
1268
- ?.labelToValue.get(label.toLowerCase());
1269
- let raw = (byLabel !== undefined ? byLabel : Number(label));
1270
- if (!Number.isFinite(raw))
1271
- raw = undefined;
1382
+ return (jsxRuntime.jsxs("div", { className: "flex items-center gap-2 mb-1", children: [jsxRuntime.jsxs("label", { className: "w-28", children: [h, jsxRuntime.jsx("span", { className: "text-gray-500 ml-1 text-[11px]", children: selectedDesc?.inputs?.[h] })] }), hasValidation && (jsxRuntime.jsx(IssueBadge, { level: hasErr ? "error" : "warning", size: 24, className: "ml-1 w-6 h-6", title: title })), isEnum ? (jsxRuntime.jsxs("select", { className: "border border-gray-300 rounded px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500 w-full", value: current !== undefined && current !== null
1383
+ ? String(current)
1384
+ : "", onChange: (e) => {
1385
+ const val = e.target.value;
1386
+ const raw = val === "" ? undefined : Number(val);
1272
1387
  setInput(h, raw);
1273
- const display = toString(typeId, raw);
1388
+ // keep drafts/originals in sync with label for display elsewhere
1389
+ const display = safeToString(typeId, raw);
1274
1390
  setDrafts((d) => ({ ...d, [h]: display }));
1275
1391
  setOriginals((o) => ({ ...o, [h]: display }));
1276
- }, ...commonProps, children: [jsxRuntime.jsx("option", { value: "", children: "(select)" }), registry.enums.get(typeId)?.options.map((opt) => (jsxRuntime.jsx("option", { value: opt.label, children: opt.label }, opt.value)))] })) : (jsxRuntime.jsx("input", { className: "border border-gray-300 rounded px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500 w-full", placeholder: isLinked ? "wired" : undefined, value: value, onChange: (e) => onChangeText(e.target.value), onBlur: commit, onKeyDown: (e) => {
1392
+ }, ...commonProps, children: [jsxRuntime.jsx("option", { value: "", children: "(select)" }), registry.enums.get(typeId)?.options.map((opt) => (jsxRuntime.jsx("option", { value: String(opt.value), children: opt.label }, opt.value)))] })) : isLinked ? (toElement(typeId, current)) : (jsxRuntime.jsx("input", { className: "border border-gray-300 rounded px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500 w-full", placeholder: isLinked ? "wired" : undefined, value: value, onChange: (e) => onChangeText(e.target.value), onBlur: commit, onKeyDown: (e) => {
1277
1393
  if (e.key === "Enter")
1278
1394
  commit();
1279
1395
  if (e.key === "Escape")
@@ -1291,74 +1407,8 @@ function Inspector({ debug, autoScroll, hideWorkbench, onAutoScrollChange, onHid
1291
1407
  })()] }, 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 }) }))] }));
1292
1408
  }
1293
1409
 
1294
- function toReactFlow(def, positions, registry, selectedNodeIds, selectedEdgeIds, opts) {
1295
- const nodeHandleMap = {};
1296
- const nodes = def.nodes.map((n) => {
1297
- const desc = registry.nodes.get(n.typeId);
1298
- const inputHandles = Object.entries(desc?.inputs ?? {}).map(([id, typeId]) => ({ id, typeId }));
1299
- const outputHandles = Object.entries(desc?.outputs ?? {}).map(([id, typeId]) => ({ id, typeId }));
1300
- nodeHandleMap[n.nodeId] = {
1301
- inputs: new Set(inputHandles.map((h) => h.id)),
1302
- outputs: new Set(outputHandles.map((h) => h.id)),
1303
- };
1304
- return {
1305
- id: n.nodeId,
1306
- data: {
1307
- typeId: n.typeId,
1308
- params: n.params,
1309
- inputHandles,
1310
- outputHandles,
1311
- showValues: opts?.showValues,
1312
- inputValues: opts?.inputs?.[n.nodeId],
1313
- outputValues: opts?.outputs?.[n.nodeId],
1314
- status: opts?.nodeStatus?.[n.nodeId],
1315
- validation: {
1316
- inputs: opts?.nodeValidation?.inputs?.[n.nodeId] ?? [],
1317
- outputs: opts?.nodeValidation?.outputs?.[n.nodeId] ?? [],
1318
- issues: opts?.nodeValidation?.issues?.[n.nodeId] ?? [],
1319
- },
1320
- toString: opts?.toString,
1321
- toElement: opts?.toElement,
1322
- },
1323
- position: positions[n.nodeId] ?? { x: 0, y: 0 },
1324
- type: opts?.resolveNodeType?.(n.typeId) ?? "spark:default",
1325
- selected: selectedNodeIds ? selectedNodeIds.has(n.nodeId) : undefined,
1326
- };
1327
- });
1328
- const edges = def.edges
1329
- .filter((e) => {
1330
- const src = nodeHandleMap[e.source.nodeId];
1331
- const dst = nodeHandleMap[e.target.nodeId];
1332
- if (!src || !dst)
1333
- return false;
1334
- return (src.outputs.has(e.source.handle) && dst.inputs.has(e.target.handle));
1335
- })
1336
- .map((e) => {
1337
- const st = opts?.edgeStatus?.[e.id];
1338
- const isRunning = !!st?.running;
1339
- const hasError = !!st?.lastError;
1340
- const isInvalidEdge = !!opts?.edgeValidation?.[e.id];
1341
- const style = hasError || isInvalidEdge
1342
- ? { stroke: "#ef4444", strokeWidth: 2 }
1343
- : isRunning
1344
- ? { stroke: "#3b82f6" }
1345
- : undefined;
1346
- return {
1347
- id: e.id,
1348
- source: e.source.nodeId,
1349
- target: e.target.nodeId,
1350
- sourceHandle: e.source.handle,
1351
- targetHandle: e.target.handle,
1352
- selected: selectedEdgeIds ? selectedEdgeIds.has(e.id) : undefined,
1353
- animated: isRunning,
1354
- style,
1355
- };
1356
- });
1357
- return { nodes, edges };
1358
- }
1359
-
1360
1410
  const DefaultNode = React.memo(function DefaultNode({ id, data, selected, isConnectable, }) {
1361
- const { typeId, showValues, inputValues, outputValues, toString, toElement, } = data;
1411
+ const { typeId, showValues, inputValues, outputValues, toString } = data;
1362
1412
  const inputEntries = data.inputHandles ?? [];
1363
1413
  const outputEntries = data.outputHandles ?? [];
1364
1414
  const status = data.status ?? {};
@@ -1374,19 +1424,26 @@ const DefaultNode = React.memo(function DefaultNode({ id, data, selected, isConn
1374
1424
  const minWidth = data.showValues ? 320 : 160;
1375
1425
  const topFor = (i) => HEADER_SIZE + i * ROW_SIZE + ROW_SIZE / 2;
1376
1426
  const hasError = !!status.lastError;
1427
+ const hasValidationError = validation.issues.some((i) => i.level === "error");
1428
+ const hasValidationWarning = !hasValidationError && validation.issues.length > 0;
1377
1429
  const isRunning = !!status.running;
1378
1430
  const isInvalid = !!status.invalidated && !isRunning && !hasError;
1379
- const borderClasses = selected
1380
- ? "border-2 border-gray-900 dark:border-gray-100"
1381
- : hasError
1382
- ? "border-2 border-red-500"
1431
+ // Border color encodes severity; thickness encodes selection; style (dashed) encodes invalidated
1432
+ const borderWidth = selected ? "border-2" : "border";
1433
+ const borderStyle = isInvalid ? "border-dashed" : "border-solid";
1434
+ const borderColor = hasError || hasValidationError
1435
+ ? "border-red-500"
1436
+ : hasValidationWarning
1437
+ ? "border-amber-500"
1383
1438
  : isRunning
1384
- ? "border-2 border-blue-500 ring-2 ring-blue-200 dark:ring-blue-900"
1385
- : isInvalid
1386
- ? "border-2 border-amber-500 border-dashed"
1387
- : "border border-gray-500 dark:border-gray-400";
1439
+ ? "border-blue-500"
1440
+ : "border-gray-500 dark:border-gray-400";
1441
+ const ringClasses = isRunning
1442
+ ? "ring-2 ring-blue-200 dark:ring-blue-900"
1443
+ : undefined;
1444
+ const borderClasses = cx(borderWidth, borderStyle, borderColor, ringClasses);
1388
1445
  const pct = Math.round(Math.max(0, Math.min(1, Number(status.progress) || 0)) * 100);
1389
- return (jsxRuntime.jsxs("div", { className: cx("rounded-lg bg-white/70 !dark:bg-stone-900 border-solid", 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")
1446
+ 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")
1390
1447
  ? "error"
1391
1448
  : "warning", size: 12, className: "w-3 h-3", title: validation.issues
1392
1449
  .map((v) => `${v.code}: ${v.message}`)
@@ -1397,8 +1454,7 @@ const DefaultNode = React.memo(function DefaultNode({ id, data, selected, isConn
1397
1454
  const title = vIssues
1398
1455
  .map((v) => `${v.code}: ${v.message}`)
1399
1456
  .join("; ");
1400
- 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: { top: topFor(i) - 8 }, title: `${entry.id}: ${entry.typeId}`, children: [entry.id, hasAny && (jsxRuntime.jsx(IssueBadge, { level: hasErr ? "error" : "warning", size: 12, className: "ml-1", title: title })), showValues &&
1401
- (toElement ? (toElement(entry.typeId, inputValues?.[entry.id])) : toString ? (jsxRuntime.jsx("span", { className: "ml-1 opacity-60", children: toString(entry.typeId, inputValues?.[entry.id]) })) : (jsxRuntime.jsx("span", { className: "ml-1 opacity-60", children: String(inputValues?.[entry.id]) })))] })] }, `in-${entry.id}`));
1457
+ 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: { top: topFor(i) - 8 }, 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}`));
1402
1458
  }), outputEntries.map((entry, i) => {
1403
1459
  const vIssues = validation.outputs.filter((v) => v.handle === entry.id);
1404
1460
  const hasAny = vIssues.length > 0;
@@ -1406,8 +1462,7 @@ const DefaultNode = React.memo(function DefaultNode({ id, data, selected, isConn
1406
1462
  const title = vIssues
1407
1463
  .map((v) => `${v.code}: ${v.message}`)
1408
1464
  .join("; ");
1409
- 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: { top: topFor(i) - 8, textAlign: "right" }, title: `${entry.id}: ${entry.typeId}`, children: [entry.id, hasAny && (jsxRuntime.jsx(IssueBadge, { level: hasErr ? "error" : "warning", size: 12, className: "ml-1", title: title })), showValues &&
1410
- (toElement ? (toElement(entry.typeId, outputValues?.[entry.id])) : toString ? (jsxRuntime.jsx("span", { className: "ml-1 opacity-60", children: toString(entry.typeId, outputValues?.[entry.id]) })) : (jsxRuntime.jsx("span", { className: "ml-1 opacity-60", children: String(outputValues?.[entry.id]) })))] })] }, `out-${entry.id}`));
1465
+ 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: { top: topFor(i) - 8, textAlign: "right" }, 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, outputValues?.[entry.id]) }))] })] }, `out-${entry.id}`));
1411
1466
  })] }));
1412
1467
  });
1413
1468
  DefaultNode.displayName = "DefaultNode";
@@ -1415,15 +1470,135 @@ DefaultNode.displayName = "DefaultNode";
1415
1470
  function DefaultContextMenu({ open, clientPos, onAdd, onClose, }) {
1416
1471
  const { registry } = useWorkbenchContext();
1417
1472
  const rf = ReactFlow.useReactFlow();
1473
+ const ids = Array.from(registry.nodes.keys());
1474
+ // Group node ids by the segment before the first '.'
1475
+ const grouped = {};
1476
+ for (const id of ids) {
1477
+ const parts = id.split(".");
1478
+ const cat = parts.length > 1 ? parts[0] : "other";
1479
+ const label = parts.length > 1 ? parts.slice(1).join(".") : id;
1480
+ (grouped[cat] = grouped[cat] || []).push({ id, label });
1481
+ }
1482
+ const cats = Object.keys(grouped).sort((a, b) => a.localeCompare(b));
1483
+ cats.forEach((c) => grouped[c].sort((a, b) => a.label.localeCompare(b.label)));
1484
+ const totalCount = ids.length;
1485
+ // Ref for focus/outside click handling
1486
+ const ref = React.useRef(null);
1487
+ // Close on outside click and on ESC
1488
+ React.useEffect(() => {
1489
+ if (!open)
1490
+ return;
1491
+ const onDown = (e) => {
1492
+ if (!ref.current)
1493
+ return;
1494
+ if (!ref.current.contains(e.target))
1495
+ onClose();
1496
+ };
1497
+ const onKey = (e) => {
1498
+ if (e.key === "Escape")
1499
+ onClose();
1500
+ };
1501
+ window.addEventListener("mousedown", onDown, true);
1502
+ window.addEventListener("keydown", onKey);
1503
+ return () => {
1504
+ window.removeEventListener("mousedown", onDown, true);
1505
+ window.removeEventListener("keydown", onKey);
1506
+ };
1507
+ }, [open, onClose]);
1508
+ // Focus for keyboard accessibility
1509
+ React.useEffect(() => {
1510
+ if (open)
1511
+ ref.current?.focus();
1512
+ }, [open]);
1418
1513
  if (!open || !clientPos)
1419
1514
  return null;
1420
- const items = Array.from(registry.nodes.keys());
1515
+ // Clamp menu position to viewport
1516
+ const MENU_MIN_WIDTH = 180;
1517
+ const PADDING = 16; // rough padding/shadow
1518
+ const x = Math.min(clientPos.x, (typeof window !== "undefined" ? window.innerWidth : 0) -
1519
+ (MENU_MIN_WIDTH + PADDING));
1520
+ const y = Math.min(clientPos.y, (typeof window !== "undefined" ? window.innerHeight : 0) - 240);
1421
1521
  const handleClick = (typeId) => {
1422
- const p = rf.project({ x: clientPos.x, y: clientPos.y });
1522
+ // project() is deprecated; use screenToFlowPosition for screen coordinates
1523
+ const p = rf.screenToFlowPosition({ x: clientPos.x, y: clientPos.y });
1423
1524
  onAdd(typeId, p);
1424
1525
  onClose();
1425
1526
  };
1426
- return (jsxRuntime.jsxs("div", { className: "fixed z-[1000] bg-white border border-gray-300 rounded-lg shadow-lg p-1 min-w-[180px] text-sm text-gray-700", style: { left: clientPos.x, top: clientPos.y }, onMouseLeave: onClose, children: [jsxRuntime.jsx("div", { className: "px-2 py-1 font-semibold text-gray-700", children: "Add Node" }), jsxRuntime.jsx("div", { className: "max-h-60 overflow-auto", children: items.map((id) => (jsxRuntime.jsx("button", { onClick: () => handleClick(id), className: "block w-full text-left px-2 py-1 hover:bg-gray-100 cursor-pointer", children: id }, id))) })] }));
1527
+ return (jsxRuntime.jsxs("div", { ref: ref, tabIndex: -1, className: "fixed z-[1000] bg-white border border-gray-300 rounded-none shadow-lg p-1 min-w-[180px] text-sm text-gray-700", style: { left: x, top: y }, onClick: (e) => e.stopPropagation(), onMouseDown: (e) => e.stopPropagation(), onWheel: (e) => e.stopPropagation(), onContextMenu: (e) => {
1528
+ e.preventDefault();
1529
+ e.stopPropagation();
1530
+ }, children: [jsxRuntime.jsxs("div", { className: "px-2 py-1 font-semibold text-gray-700", children: ["Add Node ", jsxRuntime.jsxs("span", { className: "text-gray-500 font-normal", children: ["(", totalCount, ")"] })] }), jsxRuntime.jsx("div", { className: "max-h-60 overflow-auto", children: cats.map((cat) => (jsxRuntime.jsxs("div", { className: "py-1", children: [jsxRuntime.jsxs("div", { className: "px-2 py-1 text-[11px] uppercase tracking-wide text-gray-400", children: [cat, " ", jsxRuntime.jsxs("span", { className: "opacity-60 normal-case", children: ["(", grouped[cat].length, ")"] })] }), grouped[cat].map(({ id, label }) => (jsxRuntime.jsx("button", { onClick: () => handleClick(id), className: "block w-full text-left px-3 py-1 hover:bg-gray-100 cursor-pointer", title: id, children: label }, id)))] }, cat))) })] }));
1531
+ }
1532
+
1533
+ function NodeContextMenu({ open, clientPos, nodeId, onClose, }) {
1534
+ const { wb, runner, engineKind } = useWorkbenchContext();
1535
+ const ref = React.useRef(null);
1536
+ // outside click + ESC
1537
+ React.useEffect(() => {
1538
+ if (!open)
1539
+ return;
1540
+ const onDown = (e) => {
1541
+ if (!ref.current)
1542
+ return;
1543
+ if (!ref.current.contains(e.target))
1544
+ onClose();
1545
+ };
1546
+ const onKey = (e) => {
1547
+ if (e.key === "Escape")
1548
+ onClose();
1549
+ };
1550
+ window.addEventListener("mousedown", onDown, true);
1551
+ window.addEventListener("keydown", onKey);
1552
+ return () => {
1553
+ window.removeEventListener("mousedown", onDown, true);
1554
+ window.removeEventListener("keydown", onKey);
1555
+ };
1556
+ }, [open, onClose]);
1557
+ React.useEffect(() => {
1558
+ if (open)
1559
+ ref.current?.focus();
1560
+ }, [open]);
1561
+ if (!open || !clientPos || !nodeId)
1562
+ return null;
1563
+ // clamp
1564
+ const MENU_MIN_WIDTH = 180;
1565
+ const PADDING = 16;
1566
+ const x = Math.min(clientPos.x, (typeof window !== "undefined" ? window.innerWidth : 0) -
1567
+ (MENU_MIN_WIDTH + PADDING));
1568
+ const y = Math.min(clientPos.y, (typeof window !== "undefined" ? window.innerHeight : 0) - 240);
1569
+ // actions
1570
+ const handleDelete = () => {
1571
+ wb.removeNode(nodeId);
1572
+ onClose();
1573
+ };
1574
+ const handleDuplicate = () => {
1575
+ const def = wb.export();
1576
+ const n = def.nodes.find((n) => n.nodeId === nodeId);
1577
+ if (!n)
1578
+ return onClose();
1579
+ const pos = wb.getPositions?.()[nodeId] || { x: 0, y: 0 };
1580
+ wb.addNode({ typeId: n.typeId, params: n.params, position: { x: pos.x + 24, y: pos.y + 24 } });
1581
+ onClose();
1582
+ };
1583
+ const handleCopyId = async () => {
1584
+ try {
1585
+ await navigator.clipboard.writeText(nodeId);
1586
+ }
1587
+ catch { }
1588
+ onClose();
1589
+ };
1590
+ const canRunPull = engineKind()?.toString() === "pull";
1591
+ const handleRunPull = async () => {
1592
+ try {
1593
+ await runner.computeNode(nodeId);
1594
+ }
1595
+ catch { }
1596
+ onClose();
1597
+ };
1598
+ return (jsxRuntime.jsxs("div", { ref: ref, tabIndex: -1, className: "fixed z-[1000] bg-white border border-gray-300 rounded-lg shadow-lg p-1 min-w-[180px] text-sm text-gray-700", style: { left: x, top: y }, onClick: (e) => e.stopPropagation(), onMouseDown: (e) => e.stopPropagation(), onWheel: (e) => e.stopPropagation(), onContextMenu: (e) => {
1599
+ e.preventDefault();
1600
+ e.stopPropagation();
1601
+ }, children: [jsxRuntime.jsxs("div", { className: "px-2 py-1 font-semibold text-gray-700", children: ["Node (", nodeId, ")"] }), jsxRuntime.jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handleDelete, children: "Delete" }), jsxRuntime.jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handleDuplicate, children: "Duplicate" }), canRunPull && (jsxRuntime.jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handleRunPull, children: "Run (pull)" })), jsxRuntime.jsx("div", { className: "h-px bg-gray-200 my-1" }), jsxRuntime.jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handleCopyId, children: "Copy Node ID" })] }));
1427
1602
  }
1428
1603
 
1429
1604
  function WorkbenchCanvas({ showValues, toString, toElement, }) {
@@ -1441,18 +1616,21 @@ function WorkbenchCanvas({ showValues, toString, toElement, }) {
1441
1616
  if (renderer)
1442
1617
  custom.set(typeId, renderer);
1443
1618
  }
1444
- const types = { "spark:default": DefaultNode };
1619
+ const types = {
1620
+ "spark-default": DefaultNode,
1621
+ default: DefaultNode,
1622
+ };
1445
1623
  for (const [typeId, comp] of custom.entries()) {
1446
- types[`spark:${typeId}`] = comp;
1624
+ types[`spark-${typeId}`] = comp;
1447
1625
  }
1448
- const resolver = (nodeTypeId) => custom.has(nodeTypeId) ? `spark:${nodeTypeId}` : "spark:default";
1626
+ const resolver = (nodeTypeId) => custom.has(nodeTypeId) ? `spark-${nodeTypeId}` : "spark-default";
1449
1627
  return { nodeTypes: types, resolveNodeType: resolver };
1450
1628
  // registry is stable; ui renderers expected to be set up before mount
1451
1629
  }, [wb, registry]);
1452
1630
  const { nodes, edges } = React.useMemo(() => {
1453
1631
  const def = wb.export();
1454
1632
  const sel = wb.getSelection();
1455
- return toReactFlow(def, wb.getPositions(), registry, new Set(sel.nodes), new Set(sel.edges), {
1633
+ return toReactFlow(def, wb.getPositions(), registry, {
1456
1634
  showValues,
1457
1635
  inputs: ioValues.inputs,
1458
1636
  outputs: ioValues.outputs,
@@ -1463,6 +1641,8 @@ function WorkbenchCanvas({ showValues, toString, toElement, }) {
1463
1641
  edgeStatus,
1464
1642
  nodeValidation,
1465
1643
  edgeValidation,
1644
+ selectedNodeIds: new Set(sel.nodes),
1645
+ selectedEdgeIds: new Set(sel.edges),
1466
1646
  });
1467
1647
  }, [
1468
1648
  showValues,
@@ -1477,15 +1657,31 @@ function WorkbenchCanvas({ showValues, toString, toElement, }) {
1477
1657
  ]);
1478
1658
  const [menuOpen, setMenuOpen] = React.useState(false);
1479
1659
  const [menuPos, setMenuPos] = React.useState(null);
1660
+ const [nodeMenuOpen, setNodeMenuOpen] = React.useState(false);
1661
+ const [nodeMenuPos, setNodeMenuPos] = React.useState(null);
1662
+ const [nodeAtMenu, setNodeAtMenu] = React.useState(null);
1480
1663
  const onContextMenu = (e) => {
1481
1664
  e.preventDefault();
1482
- setMenuPos({ x: e.clientX, y: e.clientY });
1483
- setMenuOpen(true);
1665
+ // Determine if right-clicked over a node by hit-testing selection
1666
+ const target = e.target?.closest(".react-flow__node");
1667
+ if (target) {
1668
+ // Resolve node id from data-id attribute React Flow sets
1669
+ const nodeId = target.getAttribute("data-id");
1670
+ setNodeAtMenu(nodeId);
1671
+ setNodeMenuPos({ x: e.clientX, y: e.clientY });
1672
+ setNodeMenuOpen(true);
1673
+ setMenuOpen(false);
1674
+ }
1675
+ else {
1676
+ setMenuPos({ x: e.clientX, y: e.clientY });
1677
+ setMenuOpen(true);
1678
+ setNodeMenuOpen(false);
1679
+ }
1484
1680
  };
1485
1681
  const addNodeAt = (typeId, pos) => {
1486
1682
  wb.addNode({ typeId, position: pos });
1487
1683
  };
1488
- 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, 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) })] }) }));
1684
+ 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, 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) })] }) }));
1489
1685
  }
1490
1686
 
1491
1687
  function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, example, onExampleChange, engine, onEngineChange, backendKind, onBackendKindChange, httpBaseUrl, onHttpBaseUrlChange, wsUrl, onWsUrlChange, debug, onDebugChange, showValues, onShowValuesChange, hideWorkbench, onHideWorkbenchChange, overrides, }) {
@@ -1778,7 +1974,7 @@ function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, ex
1778
1974
  return String(value);
1779
1975
  }, [registry]);
1780
1976
  const baseToElement = React.useCallback((typeId, value) => {
1781
- return jsxRuntime.jsx("span", { children: baseToString(typeId, value) });
1977
+ return (jsxRuntime.jsx("span", { className: "ml-1 opacity-60", children: baseToString(typeId, value) }));
1782
1978
  }, [baseToString]);
1783
1979
  const toString = React.useMemo(() => {
1784
1980
  if (overrides?.toString)
@@ -1809,7 +2005,7 @@ function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, ex
1809
2005
  catch (err) {
1810
2006
  alert(String(err?.message ?? err));
1811
2007
  }
1812
- }, disabled: !engine, children: "Start" })), jsxRuntime.jsx("button", { onClick: runAutoLayout, children: "Auto Layout" }), 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, { showValues: showValues, toString: toString, toElement: toElement }, exampleState) }), jsxRuntime.jsx(Inspector, { setInput: setInput, debug: debug, autoScroll: autoScroll, hideWorkbench: hideWorkbench, onAutoScrollChange: onAutoScrollChange, onHideWorkbenchChange: onHideWorkbenchChange, toString: toString, toElement: toElement })] })] }));
2008
+ }, disabled: !engine, children: "Start" })), jsxRuntime.jsx("button", { onClick: runAutoLayout, children: "Auto Layout" }), 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, { 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 })] })] }));
1813
2009
  }
1814
2010
  function WorkbenchStudio({ engine, onEngineChange, example, onExampleChange, backendKind, onBackendKindChange, httpBaseUrl, onHttpBaseUrlChange, wsUrl, onWsUrlChange, debug, onDebugChange, showValues, onShowValuesChange, hideWorkbench, onHideWorkbenchChange, autoScroll, onAutoScrollChange, overrides, }) {
1815
2011
  const [registry, setRegistry] = React.useState(sparkGraph.createSimpleGraphRegistry());
@@ -1841,12 +2037,12 @@ exports.DefaultUIExtensionRegistry = DefaultUIExtensionRegistry;
1841
2037
  exports.GraphRunner = GraphRunner;
1842
2038
  exports.InMemoryWorkbench = InMemoryWorkbench;
1843
2039
  exports.Inspector = Inspector;
1844
- exports.ReactFlowWorkbench = ReactFlowWorkbench;
1845
2040
  exports.WorkbenchCanvas = WorkbenchCanvas;
1846
2041
  exports.WorkbenchContext = WorkbenchContext;
1847
2042
  exports.WorkbenchProvider = WorkbenchProvider;
1848
2043
  exports.WorkbenchStudio = WorkbenchStudio;
1849
- exports.toReactFlow = toReactFlow$1;
2044
+ exports.getNodeBorderClassNames = getNodeBorderClassNames;
2045
+ exports.toReactFlow = toReactFlow;
1850
2046
  exports.useQueryParamBoolean = useQueryParamBoolean;
1851
2047
  exports.useQueryParamString = useQueryParamString;
1852
2048
  exports.useWorkbenchBridge = useWorkbenchBridge;