@bian-womp/spark-workbench 0.1.10 → 0.1.12

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 (43) hide show
  1. package/lib/cjs/index.cjs +426 -178
  2. package/lib/cjs/index.cjs.map +1 -1
  3. package/lib/cjs/src/core/contracts.d.ts +2 -2
  4. package/lib/cjs/src/core/contracts.d.ts.map +1 -1
  5. package/lib/cjs/src/index.d.ts +1 -1
  6. package/lib/cjs/src/index.d.ts.map +1 -1
  7. package/lib/cjs/src/misc/DefaultContextMenu.d.ts.map +1 -1
  8. package/lib/cjs/src/misc/DefaultNode.d.ts.map +1 -1
  9. package/lib/cjs/src/misc/Inspector.d.ts.map +1 -1
  10. package/lib/cjs/src/misc/NodeContextMenu.d.ts +10 -0
  11. package/lib/cjs/src/misc/NodeContextMenu.d.ts.map +1 -0
  12. package/lib/cjs/src/misc/WorkbenchCanvas.d.ts +2 -2
  13. package/lib/cjs/src/misc/WorkbenchCanvas.d.ts.map +1 -1
  14. package/lib/cjs/src/misc/WorkbenchStudio.d.ts.map +1 -1
  15. package/lib/cjs/src/misc/context/WorkbenchContext.provider.d.ts.map +1 -1
  16. package/lib/cjs/src/misc/mapping.d.ts +35 -4
  17. package/lib/cjs/src/misc/mapping.d.ts.map +1 -1
  18. package/lib/cjs/src/runtime/GraphRunner.d.ts +1 -0
  19. package/lib/cjs/src/runtime/GraphRunner.d.ts.map +1 -1
  20. package/lib/esm/index.js +426 -178
  21. package/lib/esm/index.js.map +1 -1
  22. package/lib/esm/src/core/contracts.d.ts +2 -2
  23. package/lib/esm/src/core/contracts.d.ts.map +1 -1
  24. package/lib/esm/src/index.d.ts +1 -1
  25. package/lib/esm/src/index.d.ts.map +1 -1
  26. package/lib/esm/src/misc/DefaultContextMenu.d.ts.map +1 -1
  27. package/lib/esm/src/misc/DefaultNode.d.ts.map +1 -1
  28. package/lib/esm/src/misc/Inspector.d.ts.map +1 -1
  29. package/lib/esm/src/misc/NodeContextMenu.d.ts +10 -0
  30. package/lib/esm/src/misc/NodeContextMenu.d.ts.map +1 -0
  31. package/lib/esm/src/misc/WorkbenchCanvas.d.ts +2 -2
  32. package/lib/esm/src/misc/WorkbenchCanvas.d.ts.map +1 -1
  33. package/lib/esm/src/misc/WorkbenchStudio.d.ts.map +1 -1
  34. package/lib/esm/src/misc/context/WorkbenchContext.provider.d.ts.map +1 -1
  35. package/lib/esm/src/misc/mapping.d.ts +35 -4
  36. package/lib/esm/src/misc/mapping.d.ts.map +1 -1
  37. package/lib/esm/src/runtime/GraphRunner.d.ts +1 -0
  38. package/lib/esm/src/runtime/GraphRunner.d.ts.map +1 -1
  39. package/package.json +4 -4
  40. package/lib/cjs/src/adapters/react-flow/index.d.ts +0 -31
  41. package/lib/cjs/src/adapters/react-flow/index.d.ts.map +0 -1
  42. package/lib/esm/src/adapters/react-flow/index.d.ts +0 -31
  43. package/lib/esm/src/adapters/react-flow/index.d.ts.map +0 -1
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,10 @@ 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
+ this.runtime.pause();
374
347
  this.runtime.update(def, this.registry);
348
+ this.runtime.resume();
375
349
  this.emit("invalidate", { reason: "graph-updated" });
376
350
  return;
377
351
  }
@@ -434,6 +408,8 @@ class GraphRunner {
434
408
  // Remote: build remotely then launch
435
409
  void this.ensureRemote().then(async (rc) => {
436
410
  await rc.runner.build(def);
411
+ // Signal UI after remote build as well
412
+ this.emit("invalidate", { reason: "graph-built" });
437
413
  const eng = rc.runner.getEngine();
438
414
  if (!rc.listenersBound) {
439
415
  eng.on("value", (e) => {
@@ -463,8 +439,58 @@ class GraphRunner {
463
439
  if (!this.stagedInputs[nodeId])
464
440
  this.stagedInputs[nodeId] = {};
465
441
  this.stagedInputs[nodeId][handle] = value;
466
- if (this.engine)
442
+ if (this.engine) {
467
443
  this.engine.setInput(nodeId, handle, value);
444
+ }
445
+ else {
446
+ // Emit a value event so UI updates even when engine isn't running
447
+ this.emit("value", { nodeId, handle, value, io: "input" });
448
+ }
449
+ }
450
+ // Batch update multiple inputs on a node and trigger a single run
451
+ setInputs(nodeId, inputs) {
452
+ if (!inputs)
453
+ return;
454
+ if (!this.stagedInputs[nodeId])
455
+ this.stagedInputs[nodeId] = {};
456
+ Object.assign(this.stagedInputs[nodeId], inputs);
457
+ // Local running: pause, set all inputs, resume, schedule a single recompute
458
+ if (this.backend.kind === "local" && this.engine && this.runtime) {
459
+ this.runtime.pause();
460
+ try {
461
+ for (const [handle, value] of Object.entries(inputs)) {
462
+ this.engine.setInput(nodeId, handle, value);
463
+ }
464
+ }
465
+ finally {
466
+ this.runtime.resume();
467
+ try {
468
+ this.runtime.__unsafe_scheduleInputsChanged(nodeId);
469
+ }
470
+ catch { }
471
+ }
472
+ }
473
+ // Remote running: forward inputs individually (no batch API available)
474
+ else if (this.engine && this.backend.kind !== "local") {
475
+ // Prefer batch if supported by remote engine
476
+ if (this.engine instanceof sparkRemote.RemoteEngine) {
477
+ this.engine.setInputs(nodeId, inputs);
478
+ }
479
+ else {
480
+ console.warn("Remote engine does not support setInputs");
481
+ for (const [handle, value] of Object.entries(inputs)) {
482
+ this.engine.setInput(nodeId, handle, value);
483
+ }
484
+ }
485
+ }
486
+ // Not running: emit value events so UI reflects staged values
487
+ else if (!this.engine) {
488
+ // Not running: emit a single synthetic value event per handle; UI will coalesce
489
+ console.warn("Remote engine does not exists");
490
+ for (const [handle, value] of Object.entries(inputs)) {
491
+ this.emit("value", { nodeId, handle, value, io: "input" });
492
+ }
493
+ }
468
494
  }
469
495
  async step() {
470
496
  if (this.backend.kind !== "local")
@@ -780,6 +806,98 @@ function useQueryParamString(key, defaultValue) {
780
806
  return [val, set];
781
807
  }
782
808
 
809
+ function toReactFlow(def, positions, registry, opts) {
810
+ const nodeHandleMap = {};
811
+ const nodes = def.nodes.map((n) => {
812
+ const desc = registry.nodes.get(n.typeId);
813
+ const inputHandles = Object.entries(desc?.inputs ?? {}).map(([id, typeId]) => ({ id, typeId }));
814
+ const outputHandles = Object.entries(desc?.outputs ?? {}).map(([id, typeId]) => ({ id, typeId }));
815
+ nodeHandleMap[n.nodeId] = {
816
+ inputs: new Set(inputHandles.map((h) => h.id)),
817
+ outputs: new Set(outputHandles.map((h) => h.id)),
818
+ };
819
+ return {
820
+ id: n.nodeId,
821
+ data: {
822
+ typeId: n.typeId,
823
+ params: n.params,
824
+ inputHandles,
825
+ outputHandles,
826
+ showValues: opts.showValues,
827
+ inputValues: opts.inputs?.[n.nodeId],
828
+ outputValues: opts.outputs?.[n.nodeId],
829
+ status: opts.nodeStatus?.[n.nodeId],
830
+ validation: {
831
+ inputs: opts.nodeValidation?.inputs?.[n.nodeId] ?? [],
832
+ outputs: opts.nodeValidation?.outputs?.[n.nodeId] ?? [],
833
+ issues: opts.nodeValidation?.issues?.[n.nodeId] ?? [],
834
+ },
835
+ toString: opts.toString,
836
+ toElement: opts.toElement,
837
+ },
838
+ position: positions[n.nodeId] ?? { x: 0, y: 0 },
839
+ type: opts.resolveNodeType?.(n.typeId) ?? "spark-default",
840
+ selected: opts.selectedNodeIds
841
+ ? opts.selectedNodeIds.has(n.nodeId)
842
+ : undefined,
843
+ };
844
+ });
845
+ const edges = def.edges
846
+ .filter((e) => {
847
+ const src = nodeHandleMap[e.source.nodeId];
848
+ const dst = nodeHandleMap[e.target.nodeId];
849
+ if (!src || !dst)
850
+ return false;
851
+ return (src.outputs.has(e.source.handle) && dst.inputs.has(e.target.handle));
852
+ })
853
+ .map((e) => {
854
+ const st = opts.edgeStatus?.[e.id];
855
+ const isRunning = !!st?.activeRuns;
856
+ const hasError = !!st?.lastError;
857
+ const isInvalidEdge = !!opts.edgeValidation?.[e.id];
858
+ const style = hasError || isInvalidEdge
859
+ ? { stroke: "#ef4444", strokeWidth: 2 }
860
+ : isRunning
861
+ ? { stroke: "#3b82f6" }
862
+ : undefined;
863
+ return {
864
+ id: e.id,
865
+ source: e.source.nodeId,
866
+ target: e.target.nodeId,
867
+ sourceHandle: e.source.handle,
868
+ targetHandle: e.target.handle,
869
+ selected: opts.selectedEdgeIds
870
+ ? opts.selectedEdgeIds.has(e.id)
871
+ : undefined,
872
+ animated: isRunning,
873
+ style,
874
+ };
875
+ });
876
+ return { nodes, edges };
877
+ }
878
+ // Shared node container border class composition for consistent visuals
879
+ function getNodeBorderClassNames(args) {
880
+ const selected = !!args.selected;
881
+ const status = args.status || {};
882
+ const issues = args.validation?.issues ?? [];
883
+ const hasError = !!status.lastError;
884
+ const hasValidationError = issues.some((i) => i?.level === "error");
885
+ const hasValidationWarning = !hasValidationError && issues.length > 0;
886
+ const isRunning = !!status.activeRuns;
887
+ const isInvalid = !!status.invalidated && !isRunning && !hasError;
888
+ const borderWidth = selected ? "border-2" : "border";
889
+ const borderStyle = isInvalid ? "border-dashed" : "border-solid";
890
+ const borderColor = hasError || hasValidationError
891
+ ? "border-red-500"
892
+ : hasValidationWarning
893
+ ? "border-amber-500"
894
+ : isRunning
895
+ ? "border-blue-500"
896
+ : "border-gray-500 dark:border-gray-400";
897
+ const ring = isRunning ? " ring-2 ring-blue-200 dark:ring-blue-900" : "";
898
+ return `${borderWidth} ${borderStyle} ${borderColor}${ring}`.trim();
899
+ }
900
+
783
901
  const WorkbenchContext = React.createContext(null);
784
902
  function useWorkbenchContext() {
785
903
  const ctx = React.useContext(WorkbenchContext);
@@ -808,6 +926,19 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, children, }) {
808
926
  const def = wb.export();
809
927
  const inputsMap = React.useMemo(() => runner.getInputs(def), [runner, def, valuesTick]);
810
928
  const outputsMap = React.useMemo(() => runner.getOutputs(def), [runner, def, valuesTick]);
929
+ // Initialize nodes as invalidated by default until first successful run
930
+ React.useEffect(() => {
931
+ setNodeStatus((prev) => {
932
+ const next = { ...prev };
933
+ for (const n of def.nodes) {
934
+ const cur = next[n.nodeId] ?? (next[n.nodeId] = {});
935
+ if (cur.invalidated === undefined) {
936
+ next[n.nodeId] = { ...cur, invalidated: true };
937
+ }
938
+ }
939
+ return next;
940
+ });
941
+ }, [def]);
811
942
  // Auto layout (simple layered layout)
812
943
  const runAutoLayout = React.useCallback(() => {
813
944
  const cur = wb.export();
@@ -875,20 +1006,20 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, children, }) {
875
1006
  const off2 = runner.on("error", (e) => {
876
1007
  const edgeError = e;
877
1008
  const nodeError = e;
878
- if (edgeError?.kind === "edge-convert") {
1009
+ if (edgeError.kind === "edge-convert") {
879
1010
  const edgeId = edgeError.edgeId;
880
1011
  setEdgeStatus((s) => ({
881
1012
  ...s,
882
1013
  [edgeId]: { ...(s[edgeId] ?? {}), lastError: edgeError.err },
883
1014
  }));
884
1015
  }
885
- else if (nodeError?.nodeId) {
886
- const nodeId = nodeError?.nodeId;
1016
+ else if (nodeError.nodeId) {
1017
+ const nodeId = nodeError.nodeId;
887
1018
  setNodeStatus((s) => ({
888
1019
  ...s,
889
1020
  [nodeId]: {
890
1021
  ...(s[nodeId] ?? {}),
891
- lastError: nodeError?.err,
1022
+ lastError: nodeError.err,
892
1023
  },
893
1024
  }));
894
1025
  }
@@ -911,15 +1042,18 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, children, }) {
911
1042
  return;
912
1043
  if (s.kind === "node-start") {
913
1044
  const id = s.nodeId;
914
- setNodeStatus((prev) => ({
915
- ...prev,
916
- [id]: {
917
- ...(prev[id] ?? {}),
918
- running: true,
919
- progress: 0,
920
- invalidated: false,
921
- },
922
- }));
1045
+ setNodeStatus((prev) => {
1046
+ const active = Math.max(1, (prev[id]?.activeRuns ?? 0) + 1);
1047
+ return {
1048
+ ...prev,
1049
+ [id]: {
1050
+ ...(prev[id] ?? {}),
1051
+ activeRuns: active,
1052
+ progress: 0,
1053
+ invalidated: false,
1054
+ },
1055
+ };
1056
+ });
923
1057
  }
924
1058
  else if (s.kind === "node-progress") {
925
1059
  const id = s.nodeId;
@@ -927,35 +1061,60 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, children, }) {
927
1061
  ...prev,
928
1062
  [id]: {
929
1063
  ...(prev[id] ?? {}),
930
- running: true,
931
1064
  progress: Number(s.progress) || 0,
932
1065
  },
933
1066
  }));
934
1067
  }
935
1068
  else if (s.kind === "node-done") {
936
1069
  const id = s.nodeId;
937
- setNodeStatus((prev) => ({
938
- ...prev,
939
- [id]: { ...(prev[id] ?? {}), running: false },
940
- }));
1070
+ setNodeStatus((prev) => {
1071
+ const current = prev[id]?.activeRuns ?? 1;
1072
+ const nextActive = Math.max(0, current - 1);
1073
+ return {
1074
+ ...prev,
1075
+ [id]: {
1076
+ ...(prev[id] ?? {}),
1077
+ activeRuns: nextActive,
1078
+ },
1079
+ };
1080
+ });
941
1081
  }
942
1082
  else if (s.kind === "edge-start") {
943
1083
  const id = s.edgeId;
944
- setEdgeStatus((prev) => ({
945
- ...prev,
946
- [id]: { ...(prev[id] ?? {}), running: true },
947
- }));
1084
+ setEdgeStatus((prev) => {
1085
+ const current = prev[id]?.activeRuns ?? 1;
1086
+ const nextActive = Math.max(0, current + 1);
1087
+ return {
1088
+ ...prev,
1089
+ [id]: { ...(prev[id] ?? {}), activeRuns: nextActive },
1090
+ };
1091
+ });
948
1092
  }
949
1093
  else if (s.kind === "edge-done") {
950
1094
  const id = s.edgeId;
951
- setEdgeStatus((prev) => ({
952
- ...prev,
953
- [id]: { ...(prev[id] ?? {}), running: false },
954
- }));
1095
+ setEdgeStatus((prev) => {
1096
+ const current = prev[id]?.activeRuns ?? 1;
1097
+ const nextActive = Math.max(0, current - 1);
1098
+ return {
1099
+ ...prev,
1100
+ [id]: { ...(prev[id] ?? {}), activeRuns: nextActive },
1101
+ };
1102
+ });
955
1103
  }
956
1104
  return add("runner", "stats")(s);
957
1105
  });
958
1106
  const off4 = wb.on("graphChanged", add("workbench", "graphChanged"));
1107
+ // Ensure newly added nodes start as invalidated until first evaluation
1108
+ const off4c = wb.on("graphChanged", (e) => {
1109
+ const change = e.change;
1110
+ if (change?.type === "addNode" && typeof change.nodeId === "string") {
1111
+ const id = change.nodeId;
1112
+ setNodeStatus((s) => ({
1113
+ ...s,
1114
+ [id]: { ...(s[id] ?? {}), invalidated: true },
1115
+ }));
1116
+ }
1117
+ });
959
1118
  const off4b = wb.on("graphUiChanged", add("workbench", "graphUiChanged"));
960
1119
  const off5 = wb.on("validationChanged", add("workbench", "validationChanged"));
961
1120
  const off5b = wb.on("validationChanged", (r) => setValidation(r));
@@ -972,6 +1131,7 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, children, }) {
972
1131
  off3b();
973
1132
  off4();
974
1133
  off4b();
1134
+ off4c();
975
1135
  off5();
976
1136
  off5b();
977
1137
  off6();
@@ -994,10 +1154,10 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, children, }) {
994
1154
  if (!validation)
995
1155
  return { inputs, outputs, issues };
996
1156
  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);
1157
+ const d = is.data;
1158
+ const level = is.level;
1159
+ const code = String(is.code ?? "");
1160
+ const message = String(is.message ?? code);
1001
1161
  if (!d)
1002
1162
  continue;
1003
1163
  if (d.nodeId) {
@@ -1026,10 +1186,10 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, children, }) {
1026
1186
  if (!validation)
1027
1187
  return list;
1028
1188
  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);
1189
+ const d = is.data;
1190
+ const level = is.level;
1191
+ const code = String(is.code ?? "");
1192
+ const message = String(is.message ?? code);
1033
1193
  if (!d || (!d.nodeId && !d.edgeId)) {
1034
1194
  list.push({ level, code, message });
1035
1195
  }
@@ -1042,10 +1202,10 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, children, }) {
1042
1202
  if (!validation)
1043
1203
  return { errors, issues };
1044
1204
  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);
1205
+ const d = is.data;
1206
+ const level = is.level;
1207
+ const code = String(is.code ?? "");
1208
+ const message = String(is.message ?? code);
1049
1209
  if (d?.edgeId) {
1050
1210
  if (level === "error")
1051
1211
  errors[d.edgeId] = true;
@@ -1156,6 +1316,16 @@ function DebugEvents({ autoScroll, onAutoScrollChange, hideWorkbench, onHideWork
1156
1316
  }
1157
1317
 
1158
1318
  function Inspector({ debug, autoScroll, hideWorkbench, onAutoScrollChange, onHideWorkbenchChange, toString, toElement, setInput, }) {
1319
+ const safeToString = (typeId, value) => {
1320
+ try {
1321
+ return typeof toString === "function"
1322
+ ? toString(typeId, value)
1323
+ : String(value ?? "");
1324
+ }
1325
+ catch {
1326
+ return String(value ?? "");
1327
+ }
1328
+ };
1159
1329
  const { registry, def, selectedNodeId, selectedEdgeId, inputsMap, outputsMap, nodeStatus, validationByNode, validationByEdge, validationGlobal, valuesTick, } = useWorkbenchContext();
1160
1330
  const nodeValidationIssues = validationByNode.issues;
1161
1331
  const edgeValidationIssues = validationByEdge.issues;
@@ -1215,7 +1385,7 @@ function Inspector({ debug, autoScroll, hideWorkbench, onAutoScrollChange, onHid
1215
1385
  for (const h of handles) {
1216
1386
  const typeId = desc?.inputs?.[h];
1217
1387
  const current = nodeInputs[h];
1218
- const display = toString(typeId, current);
1388
+ const display = safeToString(typeId, current);
1219
1389
  const wasOriginal = originals[h];
1220
1390
  const isDirty = drafts[h] !== undefined &&
1221
1391
  wasOriginal !== undefined &&
@@ -1241,7 +1411,7 @@ function Inspector({ debug, autoScroll, hideWorkbench, onAutoScrollChange, onHid
1241
1411
  disabled: isLinked,
1242
1412
  };
1243
1413
  const current = nodeInputs[h];
1244
- const value = drafts[h] ?? toString(typeId, current);
1414
+ const value = drafts[h] ?? safeToString(typeId, current);
1245
1415
  const onChangeText = (text) => setDrafts((d) => ({ ...d, [h]: text }));
1246
1416
  const commit = () => {
1247
1417
  const draft = drafts[h];
@@ -1251,7 +1421,7 @@ function Inspector({ debug, autoScroll, hideWorkbench, onAutoScrollChange, onHid
1251
1421
  setOriginals((o) => ({ ...o, [h]: draft }));
1252
1422
  };
1253
1423
  const revert = () => {
1254
- const orig = originals[h] ?? toString(typeId, current);
1424
+ const orig = originals[h] ?? safeToString(typeId, current);
1255
1425
  setDrafts((d) => ({ ...d, [h]: orig }));
1256
1426
  };
1257
1427
  const isEnum = typeId?.includes("enum:");
@@ -1261,19 +1431,17 @@ function Inspector({ debug, autoScroll, hideWorkbench, onAutoScrollChange, onHid
1261
1431
  const title = inIssues
1262
1432
  .map((v) => `${v.code}: ${v.message}`)
1263
1433
  .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;
1434
+ 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
1435
+ ? String(current)
1436
+ : "", onChange: (e) => {
1437
+ const val = e.target.value;
1438
+ const raw = val === "" ? undefined : Number(val);
1272
1439
  setInput(h, raw);
1273
- const display = toString(typeId, raw);
1440
+ // keep drafts/originals in sync with label for display elsewhere
1441
+ const display = safeToString(typeId, raw);
1274
1442
  setDrafts((d) => ({ ...d, [h]: display }));
1275
1443
  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) => {
1444
+ }, ...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
1445
  if (e.key === "Enter")
1278
1446
  commit();
1279
1447
  if (e.key === "Escape")
@@ -1291,74 +1459,8 @@ function Inspector({ debug, autoScroll, hideWorkbench, onAutoScrollChange, onHid
1291
1459
  })()] }, 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
1460
  }
1293
1461
 
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
1462
  const DefaultNode = React.memo(function DefaultNode({ id, data, selected, isConnectable, }) {
1361
- const { typeId, showValues, inputValues, outputValues, toString, toElement, } = data;
1463
+ const { typeId, showValues, inputValues, outputValues, toString } = data;
1362
1464
  const inputEntries = data.inputHandles ?? [];
1363
1465
  const outputEntries = data.outputHandles ?? [];
1364
1466
  const status = data.status ?? {};
@@ -1374,19 +1476,26 @@ const DefaultNode = React.memo(function DefaultNode({ id, data, selected, isConn
1374
1476
  const minWidth = data.showValues ? 320 : 160;
1375
1477
  const topFor = (i) => HEADER_SIZE + i * ROW_SIZE + ROW_SIZE / 2;
1376
1478
  const hasError = !!status.lastError;
1377
- const isRunning = !!status.running;
1479
+ const hasValidationError = validation.issues.some((i) => i.level === "error");
1480
+ const hasValidationWarning = !hasValidationError && validation.issues.length > 0;
1481
+ const isRunning = !!status.activeRuns;
1378
1482
  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"
1483
+ // Border color encodes severity; thickness encodes selection; style (dashed) encodes invalidated
1484
+ const borderWidth = selected ? "border-2" : "border";
1485
+ const borderStyle = isInvalid ? "border-dashed" : "border-solid";
1486
+ const borderColor = hasError || hasValidationError
1487
+ ? "border-red-500"
1488
+ : hasValidationWarning
1489
+ ? "border-amber-500"
1383
1490
  : 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";
1491
+ ? "border-blue-500"
1492
+ : "border-gray-500 dark:border-gray-400";
1493
+ const ringClasses = isRunning
1494
+ ? "ring-2 ring-blue-200 dark:ring-blue-900"
1495
+ : undefined;
1496
+ const borderClasses = cx(borderWidth, borderStyle, borderColor, ringClasses);
1388
1497
  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")
1498
+ 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
1499
  ? "error"
1391
1500
  : "warning", size: 12, className: "w-3 h-3", title: validation.issues
1392
1501
  .map((v) => `${v.code}: ${v.message}`)
@@ -1397,8 +1506,7 @@ const DefaultNode = React.memo(function DefaultNode({ id, data, selected, isConn
1397
1506
  const title = vIssues
1398
1507
  .map((v) => `${v.code}: ${v.message}`)
1399
1508
  .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}`));
1509
+ 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
1510
  }), outputEntries.map((entry, i) => {
1403
1511
  const vIssues = validation.outputs.filter((v) => v.handle === entry.id);
1404
1512
  const hasAny = vIssues.length > 0;
@@ -1406,8 +1514,7 @@ const DefaultNode = React.memo(function DefaultNode({ id, data, selected, isConn
1406
1514
  const title = vIssues
1407
1515
  .map((v) => `${v.code}: ${v.message}`)
1408
1516
  .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}`));
1517
+ 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
1518
  })] }));
1412
1519
  });
1413
1520
  DefaultNode.displayName = "DefaultNode";
@@ -1415,15 +1522,135 @@ DefaultNode.displayName = "DefaultNode";
1415
1522
  function DefaultContextMenu({ open, clientPos, onAdd, onClose, }) {
1416
1523
  const { registry } = useWorkbenchContext();
1417
1524
  const rf = ReactFlow.useReactFlow();
1525
+ const ids = Array.from(registry.nodes.keys());
1526
+ // Group node ids by the segment before the first '.'
1527
+ const grouped = {};
1528
+ for (const id of ids) {
1529
+ const parts = id.split(".");
1530
+ const cat = parts.length > 1 ? parts[0] : "other";
1531
+ const label = parts.length > 1 ? parts.slice(1).join(".") : id;
1532
+ (grouped[cat] = grouped[cat] || []).push({ id, label });
1533
+ }
1534
+ const cats = Object.keys(grouped).sort((a, b) => a.localeCompare(b));
1535
+ cats.forEach((c) => grouped[c].sort((a, b) => a.label.localeCompare(b.label)));
1536
+ const totalCount = ids.length;
1537
+ // Ref for focus/outside click handling
1538
+ const ref = React.useRef(null);
1539
+ // Close on outside click and on ESC
1540
+ React.useEffect(() => {
1541
+ if (!open)
1542
+ return;
1543
+ const onDown = (e) => {
1544
+ if (!ref.current)
1545
+ return;
1546
+ if (!ref.current.contains(e.target))
1547
+ onClose();
1548
+ };
1549
+ const onKey = (e) => {
1550
+ if (e.key === "Escape")
1551
+ onClose();
1552
+ };
1553
+ window.addEventListener("mousedown", onDown, true);
1554
+ window.addEventListener("keydown", onKey);
1555
+ return () => {
1556
+ window.removeEventListener("mousedown", onDown, true);
1557
+ window.removeEventListener("keydown", onKey);
1558
+ };
1559
+ }, [open, onClose]);
1560
+ // Focus for keyboard accessibility
1561
+ React.useEffect(() => {
1562
+ if (open)
1563
+ ref.current?.focus();
1564
+ }, [open]);
1418
1565
  if (!open || !clientPos)
1419
1566
  return null;
1420
- const items = Array.from(registry.nodes.keys());
1567
+ // Clamp menu position to viewport
1568
+ const MENU_MIN_WIDTH = 180;
1569
+ const PADDING = 16; // rough padding/shadow
1570
+ const x = Math.min(clientPos.x, (typeof window !== "undefined" ? window.innerWidth : 0) -
1571
+ (MENU_MIN_WIDTH + PADDING));
1572
+ const y = Math.min(clientPos.y, (typeof window !== "undefined" ? window.innerHeight : 0) - 240);
1421
1573
  const handleClick = (typeId) => {
1422
- const p = rf.project({ x: clientPos.x, y: clientPos.y });
1574
+ // project() is deprecated; use screenToFlowPosition for screen coordinates
1575
+ const p = rf.screenToFlowPosition({ x: clientPos.x, y: clientPos.y });
1423
1576
  onAdd(typeId, p);
1424
1577
  onClose();
1425
1578
  };
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))) })] }));
1579
+ 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) => {
1580
+ e.preventDefault();
1581
+ e.stopPropagation();
1582
+ }, 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))) })] }));
1583
+ }
1584
+
1585
+ function NodeContextMenu({ open, clientPos, nodeId, onClose, }) {
1586
+ const { wb, runner, engineKind } = useWorkbenchContext();
1587
+ const ref = React.useRef(null);
1588
+ // outside click + ESC
1589
+ React.useEffect(() => {
1590
+ if (!open)
1591
+ return;
1592
+ const onDown = (e) => {
1593
+ if (!ref.current)
1594
+ return;
1595
+ if (!ref.current.contains(e.target))
1596
+ onClose();
1597
+ };
1598
+ const onKey = (e) => {
1599
+ if (e.key === "Escape")
1600
+ onClose();
1601
+ };
1602
+ window.addEventListener("mousedown", onDown, true);
1603
+ window.addEventListener("keydown", onKey);
1604
+ return () => {
1605
+ window.removeEventListener("mousedown", onDown, true);
1606
+ window.removeEventListener("keydown", onKey);
1607
+ };
1608
+ }, [open, onClose]);
1609
+ React.useEffect(() => {
1610
+ if (open)
1611
+ ref.current?.focus();
1612
+ }, [open]);
1613
+ if (!open || !clientPos || !nodeId)
1614
+ return null;
1615
+ // clamp
1616
+ const MENU_MIN_WIDTH = 180;
1617
+ const PADDING = 16;
1618
+ const x = Math.min(clientPos.x, (typeof window !== "undefined" ? window.innerWidth : 0) -
1619
+ (MENU_MIN_WIDTH + PADDING));
1620
+ const y = Math.min(clientPos.y, (typeof window !== "undefined" ? window.innerHeight : 0) - 240);
1621
+ // actions
1622
+ const handleDelete = () => {
1623
+ wb.removeNode(nodeId);
1624
+ onClose();
1625
+ };
1626
+ const handleDuplicate = () => {
1627
+ const def = wb.export();
1628
+ const n = def.nodes.find((n) => n.nodeId === nodeId);
1629
+ if (!n)
1630
+ return onClose();
1631
+ const pos = wb.getPositions?.()[nodeId] || { x: 0, y: 0 };
1632
+ wb.addNode({ typeId: n.typeId, params: n.params, position: { x: pos.x + 24, y: pos.y + 24 } });
1633
+ onClose();
1634
+ };
1635
+ const handleCopyId = async () => {
1636
+ try {
1637
+ await navigator.clipboard.writeText(nodeId);
1638
+ }
1639
+ catch { }
1640
+ onClose();
1641
+ };
1642
+ const canRunPull = engineKind()?.toString() === "pull";
1643
+ const handleRunPull = async () => {
1644
+ try {
1645
+ await runner.computeNode(nodeId);
1646
+ }
1647
+ catch { }
1648
+ onClose();
1649
+ };
1650
+ 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) => {
1651
+ e.preventDefault();
1652
+ e.stopPropagation();
1653
+ }, 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
1654
  }
1428
1655
 
1429
1656
  function WorkbenchCanvas({ showValues, toString, toElement, }) {
@@ -1441,18 +1668,21 @@ function WorkbenchCanvas({ showValues, toString, toElement, }) {
1441
1668
  if (renderer)
1442
1669
  custom.set(typeId, renderer);
1443
1670
  }
1444
- const types = { "spark:default": DefaultNode };
1671
+ const types = {
1672
+ "spark-default": DefaultNode,
1673
+ default: DefaultNode,
1674
+ };
1445
1675
  for (const [typeId, comp] of custom.entries()) {
1446
- types[`spark:${typeId}`] = comp;
1676
+ types[`spark-${typeId}`] = comp;
1447
1677
  }
1448
- const resolver = (nodeTypeId) => custom.has(nodeTypeId) ? `spark:${nodeTypeId}` : "spark:default";
1678
+ const resolver = (nodeTypeId) => custom.has(nodeTypeId) ? `spark-${nodeTypeId}` : "spark-default";
1449
1679
  return { nodeTypes: types, resolveNodeType: resolver };
1450
1680
  // registry is stable; ui renderers expected to be set up before mount
1451
1681
  }, [wb, registry]);
1452
1682
  const { nodes, edges } = React.useMemo(() => {
1453
1683
  const def = wb.export();
1454
1684
  const sel = wb.getSelection();
1455
- return toReactFlow(def, wb.getPositions(), registry, new Set(sel.nodes), new Set(sel.edges), {
1685
+ return toReactFlow(def, wb.getPositions(), registry, {
1456
1686
  showValues,
1457
1687
  inputs: ioValues.inputs,
1458
1688
  outputs: ioValues.outputs,
@@ -1463,6 +1693,8 @@ function WorkbenchCanvas({ showValues, toString, toElement, }) {
1463
1693
  edgeStatus,
1464
1694
  nodeValidation,
1465
1695
  edgeValidation,
1696
+ selectedNodeIds: new Set(sel.nodes),
1697
+ selectedEdgeIds: new Set(sel.edges),
1466
1698
  });
1467
1699
  }, [
1468
1700
  showValues,
@@ -1477,15 +1709,31 @@ function WorkbenchCanvas({ showValues, toString, toElement, }) {
1477
1709
  ]);
1478
1710
  const [menuOpen, setMenuOpen] = React.useState(false);
1479
1711
  const [menuPos, setMenuPos] = React.useState(null);
1712
+ const [nodeMenuOpen, setNodeMenuOpen] = React.useState(false);
1713
+ const [nodeMenuPos, setNodeMenuPos] = React.useState(null);
1714
+ const [nodeAtMenu, setNodeAtMenu] = React.useState(null);
1480
1715
  const onContextMenu = (e) => {
1481
1716
  e.preventDefault();
1482
- setMenuPos({ x: e.clientX, y: e.clientY });
1483
- setMenuOpen(true);
1717
+ // Determine if right-clicked over a node by hit-testing selection
1718
+ const target = e.target?.closest(".react-flow__node");
1719
+ if (target) {
1720
+ // Resolve node id from data-id attribute React Flow sets
1721
+ const nodeId = target.getAttribute("data-id");
1722
+ setNodeAtMenu(nodeId);
1723
+ setNodeMenuPos({ x: e.clientX, y: e.clientY });
1724
+ setNodeMenuOpen(true);
1725
+ setMenuOpen(false);
1726
+ }
1727
+ else {
1728
+ setMenuPos({ x: e.clientX, y: e.clientY });
1729
+ setMenuOpen(true);
1730
+ setNodeMenuOpen(false);
1731
+ }
1484
1732
  };
1485
1733
  const addNodeAt = (typeId, pos) => {
1486
1734
  wb.addNode({ typeId, position: pos });
1487
1735
  };
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) })] }) }));
1736
+ 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
1737
  }
1490
1738
 
1491
1739
  function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, example, onExampleChange, engine, onEngineChange, backendKind, onBackendKindChange, httpBaseUrl, onHttpBaseUrlChange, wsUrl, onWsUrlChange, debug, onDebugChange, showValues, onShowValuesChange, hideWorkbench, onHideWorkbenchChange, overrides, }) {
@@ -1778,7 +2026,7 @@ function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, ex
1778
2026
  return String(value);
1779
2027
  }, [registry]);
1780
2028
  const baseToElement = React.useCallback((typeId, value) => {
1781
- return jsxRuntime.jsx("span", { children: baseToString(typeId, value) });
2029
+ return (jsxRuntime.jsx("span", { className: "ml-1 opacity-60", children: baseToString(typeId, value) }));
1782
2030
  }, [baseToString]);
1783
2031
  const toString = React.useMemo(() => {
1784
2032
  if (overrides?.toString)
@@ -1809,7 +2057,7 @@ function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, ex
1809
2057
  catch (err) {
1810
2058
  alert(String(err?.message ?? err));
1811
2059
  }
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 })] })] }));
2060
+ }, 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
2061
  }
1814
2062
  function WorkbenchStudio({ engine, onEngineChange, example, onExampleChange, backendKind, onBackendKindChange, httpBaseUrl, onHttpBaseUrlChange, wsUrl, onWsUrlChange, debug, onDebugChange, showValues, onShowValuesChange, hideWorkbench, onHideWorkbenchChange, autoScroll, onAutoScrollChange, overrides, }) {
1815
2063
  const [registry, setRegistry] = React.useState(sparkGraph.createSimpleGraphRegistry());
@@ -1841,12 +2089,12 @@ exports.DefaultUIExtensionRegistry = DefaultUIExtensionRegistry;
1841
2089
  exports.GraphRunner = GraphRunner;
1842
2090
  exports.InMemoryWorkbench = InMemoryWorkbench;
1843
2091
  exports.Inspector = Inspector;
1844
- exports.ReactFlowWorkbench = ReactFlowWorkbench;
1845
2092
  exports.WorkbenchCanvas = WorkbenchCanvas;
1846
2093
  exports.WorkbenchContext = WorkbenchContext;
1847
2094
  exports.WorkbenchProvider = WorkbenchProvider;
1848
2095
  exports.WorkbenchStudio = WorkbenchStudio;
1849
- exports.toReactFlow = toReactFlow$1;
2096
+ exports.getNodeBorderClassNames = getNodeBorderClassNames;
2097
+ exports.toReactFlow = toReactFlow;
1850
2098
  exports.useQueryParamBoolean = useQueryParamBoolean;
1851
2099
  exports.useQueryParamString = useQueryParamString;
1852
2100
  exports.useWorkbenchBridge = useWorkbenchBridge;