bosun 0.41.0 → 0.41.2

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 (64) hide show
  1. package/.env.example +8 -0
  2. package/README.md +20 -0
  3. package/agent/agent-event-bus.mjs +248 -6
  4. package/agent/agent-pool.mjs +125 -28
  5. package/agent/agent-work-analyzer.mjs +8 -16
  6. package/agent/retry-queue.mjs +164 -0
  7. package/bosun.config.example.json +25 -0
  8. package/bosun.schema.json +825 -183
  9. package/cli.mjs +59 -5
  10. package/config/config.mjs +130 -3
  11. package/infra/monitor.mjs +693 -67
  12. package/infra/runtime-accumulator.mjs +376 -84
  13. package/infra/session-tracker.mjs +82 -25
  14. package/lib/codebase-audit.mjs +133 -18
  15. package/package.json +23 -4
  16. package/server/setup-web-server.mjs +25 -0
  17. package/server/ui-server.mjs +248 -29
  18. package/setup.mjs +27 -24
  19. package/shell/codex-shell.mjs +34 -3
  20. package/shell/copilot-shell.mjs +50 -8
  21. package/task/msg-hub.mjs +193 -0
  22. package/task/pipeline.mjs +544 -0
  23. package/task/task-cli.mjs +38 -2
  24. package/task/task-executor-pipeline.mjs +143 -0
  25. package/task/task-executor.mjs +36 -27
  26. package/telegram/get-telegram-chat-id.mjs +57 -47
  27. package/ui/components/workspace-switcher.js +7 -7
  28. package/ui/demo-defaults.js +15694 -10573
  29. package/ui/modules/settings-schema.js +2 -0
  30. package/ui/modules/state.js +54 -57
  31. package/ui/modules/voice-client-sdk.js +375 -36
  32. package/ui/modules/voice-client.js +140 -31
  33. package/ui/setup.html +68 -2
  34. package/ui/styles/components.css +57 -0
  35. package/ui/styles.css +201 -1
  36. package/ui/tabs/dashboard.js +74 -0
  37. package/ui/tabs/logs.js +10 -0
  38. package/ui/tabs/settings.js +178 -99
  39. package/ui/tabs/tasks.js +31 -1
  40. package/ui/tabs/telemetry.js +34 -0
  41. package/ui/tabs/workflow-canvas-utils.mjs +8 -1
  42. package/ui/tabs/workflows.js +532 -275
  43. package/voice/voice-agents-sdk.mjs +1 -1
  44. package/voice/voice-relay.mjs +6 -6
  45. package/workflow/declarative-workflows.mjs +145 -0
  46. package/workflow/msg-hub.mjs +237 -0
  47. package/workflow/pipeline-workflows.mjs +287 -0
  48. package/workflow/pipeline.mjs +828 -315
  49. package/workflow/workflow-cli.mjs +128 -0
  50. package/workflow/workflow-engine.mjs +329 -17
  51. package/workflow/workflow-nodes/custom-loader.mjs +250 -0
  52. package/workflow/workflow-nodes.mjs +1955 -223
  53. package/workflow/workflow-templates.mjs +26 -8
  54. package/workflow-templates/agents.mjs +0 -1
  55. package/workflow-templates/bosun-native.mjs +212 -2
  56. package/workflow-templates/continuation-loop.mjs +339 -0
  57. package/workflow-templates/github.mjs +516 -40
  58. package/workflow-templates/planning.mjs +446 -17
  59. package/workflow-templates/reliability.mjs +65 -12
  60. package/workflow-templates/task-batch.mjs +24 -8
  61. package/workflow-templates/task-lifecycle.mjs +83 -6
  62. package/workspace/context-cache.mjs +66 -18
  63. package/workspace/workspace-manager.mjs +2 -1
  64. package/workflow-templates/issue-continuation.mjs +0 -243
@@ -17,7 +17,8 @@ import { ICONS } from "../modules/icons.js";
17
17
  import { resolveIcon } from "../modules/icon-utils.js";
18
18
  import { formatDate, formatDuration, formatRelative } from "../modules/utils.js";
19
19
  import {
20
- buildNodeStatusesFromRunDetail,
20
+ HISTORY_LIMIT,
21
+ HISTORY_COMMIT_DEBOUNCE_MS,
21
22
  createHistoryState,
22
23
  getNodeSearchMetadata,
23
24
  parseGraphSnapshot,
@@ -1235,109 +1236,144 @@ function stripEmoji(text) {
1235
1236
  .trim();
1236
1237
  }
1237
1238
 
1238
- function normalizeLiveNodeStatus(status) {
1239
- const normalized = String(status || "").trim().toLowerCase();
1240
- if (normalized === "completed" || normalized === "success") return "success";
1241
- if (normalized === "failed" || normalized === "fail" || normalized === "error") return "fail";
1242
- if (normalized === "skipped" || normalized === "skip") return "skipped";
1243
- if (normalized === "running") return "running";
1244
- if (normalized === "waiting" || normalized === "pending") return "waiting";
1245
- return normalized || "";
1246
- }
1247
-
1248
- function toNodeStatusLabel(status) {
1249
- if (status === "success") return "success";
1250
- if (status === "fail") return "fail";
1251
- if (status === "skipped") return "skipped";
1252
- return status || "";
1253
- }
1239
+ const PORT_TYPE_META = {
1240
+ Any: { color: "#9ca3af", description: "Wildcard payload" },
1241
+ TaskDef: { color: "#10b981", description: "Task definition/context payload" },
1242
+ TriggerEvent: { color: "#22c55e", description: "Event payload emitted by trigger nodes" },
1243
+ AgentResult: { color: "#8b5cf6", description: "Agent execution output" },
1244
+ String: { color: "#3b82f6", description: "Text payload" },
1245
+ Boolean: { color: "#14b8a6", description: "Boolean flag" },
1246
+ Number: { color: "#0ea5e9", description: "Numeric payload" },
1247
+ JSON: { color: "#06b6d4", description: "Structured JSON payload" },
1248
+ GitRef: { color: "#f97316", description: "Git branch/hash/ref payload" },
1249
+ PRUrl: { color: "#f43f5e", description: "Pull request URL payload" },
1250
+ LogStream: { color: "#eab308", description: "Log output or command transcript" },
1251
+ SessionRef: { color: "#a855f7", description: "Session identifier payload" },
1252
+ CommandResult: { color: "#f59e0b", description: "Command execution result" },
1253
+ };
1254
1254
 
1255
- function getCanvasNodeExecutionVisuals(status, isSelected, selectedColor, flashState = "") {
1256
- if (status === "running") {
1257
- return {
1258
- fill: "#10233f",
1259
- stroke: "#60a5fa",
1260
- strokeWidth: 2.5,
1261
- filter: "url(#node-glow)",
1262
- };
1263
- }
1264
- if (status === "fail" || flashState === "fail") {
1265
- return {
1266
- fill: "#2a1217",
1267
- stroke: "#ef4444",
1268
- strokeWidth: 2.25,
1269
- filter: "url(#node-shadow)",
1270
- };
1271
- }
1272
- if (status === "success" || flashState === "success") {
1273
- return {
1274
- fill: "#0f2a23",
1275
- stroke: "#10b981",
1276
- strokeWidth: 2,
1277
- filter: "url(#node-shadow)",
1278
- };
1279
- }
1280
- if (status === "skipped" || flashState === "skipped") {
1281
- return {
1282
- fill: "#1f2430",
1283
- stroke: "#94a3b8",
1284
- strokeWidth: 2,
1285
- filter: "url(#node-shadow)",
1286
- };
1287
- }
1288
- if (status === "waiting" || status === "pending") {
1255
+ function normalizePortDescriptor(port, direction, index) {
1256
+ const fallbackName = index === 0 ? "default" : `${direction}-${index + 1}`;
1257
+ if (!port || typeof port !== "object") {
1289
1258
  return {
1290
- fill: "#2f2310",
1291
- stroke: "#f59e0b",
1292
- strokeWidth: 2,
1293
- filter: "url(#node-shadow)",
1259
+ name: fallbackName,
1260
+ label: fallbackName,
1261
+ type: "Any",
1262
+ description: PORT_TYPE_META.Any.description,
1263
+ accepts: [],
1264
+ color: PORT_TYPE_META.Any.color,
1294
1265
  };
1295
1266
  }
1267
+ const type = String(port.type || "Any").trim() || "Any";
1268
+ const typeMeta = PORT_TYPE_META[type] || PORT_TYPE_META.Any;
1296
1269
  return {
1297
- fill: isSelected ? "#1e293b" : "#1a1f2e",
1298
- stroke: isSelected ? selectedColor : "#2a3040",
1299
- strokeWidth: isSelected ? 2 : 1,
1300
- filter: isSelected ? "url(#node-glow)" : "url(#node-shadow)",
1270
+ ...port,
1271
+ name: String(port.name || fallbackName).trim() || fallbackName,
1272
+ label: String(port.label || port.name || fallbackName).trim() || fallbackName,
1273
+ type,
1274
+ description: String(port.description || typeMeta.description || "").trim(),
1275
+ accepts: Array.isArray(port.accepts)
1276
+ ? Array.from(new Set(port.accepts.map((value) => String(value || "").trim()).filter(Boolean)))
1277
+ : [],
1278
+ color: String(port.color || typeMeta.color || "").trim() || typeMeta.color,
1301
1279
  };
1302
1280
  }
1303
1281
 
1304
- function resolveNodeOutputPreview(nodeType, preview = {}, fallbackOutput = null) {
1305
- const lines = Array.isArray(preview?.lines) ? preview.lines : [];
1306
- const tokenCount = Number.isFinite(Number(preview?.tokenCount))
1307
- ? Math.max(0, Math.round(Number(preview.tokenCount)))
1308
- : null;
1309
- if (lines.length) {
1310
- return {
1311
- lines: lines.slice(0, 3).map((line) => String(line || "").trim()).filter(Boolean),
1312
- tokenCount,
1313
- };
1314
- }
1315
- const type = String(nodeType || "").trim().toLowerCase();
1316
- const output = fallbackOutput && typeof fallbackOutput === "object" ? fallbackOutput : {};
1317
- const fallbackLines = [];
1318
- if (type.startsWith("condition.")) {
1319
- const branch = String(output.matchedPort || output.port || "").trim();
1320
- if (branch) fallbackLines.push(`Branch: ${branch}`);
1321
- if (Object.prototype.hasOwnProperty.call(output, "value")) {
1322
- fallbackLines.push(`Value: ${String(output.value)}`);
1323
- }
1324
- } else if (type.startsWith("git.") || type.startsWith("github.")) {
1325
- const shaRaw = String(output.commitSha || output.sha || output.head || "").trim();
1326
- const sha = /^[0-9a-f]{7,40}$/i.test(shaRaw) ? shaRaw.slice(0, 12) : "";
1327
- if (sha) fallbackLines.push(`Commit: ${sha}`);
1328
- const prUrl = String(output.prUrl || output.url || "").trim();
1329
- if (prUrl) fallbackLines.push(`PR: ${prUrl}`);
1282
+ function isWildcardPortType(type) {
1283
+ const normalized = String(type || "").trim();
1284
+ return normalized === "*" || normalized === "Any";
1285
+ }
1286
+
1287
+ function isPortConnectionCompatible(sourcePort, targetPort) {
1288
+ if (!sourcePort || !targetPort) return { compatible: true, reason: null };
1289
+ const sourceType = String(sourcePort.type || "Any").trim() || "Any";
1290
+ const targetType = String(targetPort.type || "Any").trim() || "Any";
1291
+ const accepted = new Set(
1292
+ [targetType, ...(Array.isArray(targetPort.accepts) ? targetPort.accepts : [])]
1293
+ .map((value) => String(value || "").trim())
1294
+ .filter(Boolean),
1295
+ );
1296
+ if (isWildcardPortType(sourceType) || isWildcardPortType(targetType) || accepted.has("*") || accepted.has("Any")) {
1297
+ return { compatible: true, reason: null };
1330
1298
  }
1331
- if (!fallbackLines.length) {
1332
- const text = String(output.summary || output.output || output.message || output.error || "").trim();
1333
- if (text) fallbackLines.push(...text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).slice(0, 3));
1299
+ if (sourceType === targetType || accepted.has(sourceType)) {
1300
+ return { compatible: true, reason: null };
1334
1301
  }
1335
1302
  return {
1336
- lines: fallbackLines.slice(0, 3),
1337
- tokenCount,
1303
+ compatible: false,
1304
+ reason: `${sourcePort.label || sourcePort.name} emits ${sourceType}, but ${targetPort.label || targetPort.name} expects ${targetType}`,
1338
1305
  };
1339
1306
  }
1340
1307
 
1308
+ function resolveNodePorts(node, nodeTypeMap) {
1309
+ const typeInfo = nodeTypeMap.get(node?.type) || null;
1310
+ const typePorts = typeInfo?.ports || {};
1311
+ const inputSource = Array.isArray(node?.inputPorts) && node.inputPorts.length
1312
+ ? node.inputPorts
1313
+ : typePorts.inputs;
1314
+ const outputSource = Array.isArray(node?.outputPorts) && node.outputPorts.length
1315
+ ? node.outputPorts
1316
+ : typePorts.outputs;
1317
+ const inputs = (Array.isArray(inputSource) ? inputSource : [])
1318
+ .map((port, index) => normalizePortDescriptor(port, "input", index));
1319
+ const outputs = (Array.isArray(outputSource) ? outputSource : [])
1320
+ .map((port, index) => normalizePortDescriptor(port, "output", index));
1321
+ return {
1322
+ inputs: inputs.length ? inputs : [normalizePortDescriptor(null, "input", 0)],
1323
+ outputs: outputs.length ? outputs : [normalizePortDescriptor(null, "output", 0)],
1324
+ };
1325
+ }
1326
+
1327
+ function sanitizeInlineFieldValue(value) {
1328
+ if (value == null) return "";
1329
+ if (typeof value === "object") return JSON.stringify(value);
1330
+ return value;
1331
+ }
1332
+
1333
+ function pickInlineFieldKeys(typeInfo, node, maxFields = 3) {
1334
+ const schema = typeInfo?.schema?.properties || {};
1335
+ const keys = Object.keys(schema);
1336
+ const preferred = Array.isArray(typeInfo?.ui?.primaryFields)
1337
+ ? typeInfo.ui.primaryFields
1338
+ : [];
1339
+ const selected = [];
1340
+ for (const key of preferred) {
1341
+ if (keys.includes(key) && !selected.includes(key)) selected.push(key);
1342
+ }
1343
+ if (selected.length >= maxFields) return selected.slice(0, maxFields);
1344
+ const fallbackPriority = ["model", "expression", "enabled", "branch", "branchName", "eventType", "command", "message", "prompt"];
1345
+ for (const key of fallbackPriority) {
1346
+ if (keys.includes(key) && !selected.includes(key)) selected.push(key);
1347
+ if (selected.length >= maxFields) break;
1348
+ }
1349
+ return selected.slice(0, maxFields);
1350
+ }
1351
+
1352
+ function getInlineFieldDescriptors(typeInfo, node, maxFields = 3) {
1353
+ const schema = typeInfo?.schema?.properties || {};
1354
+ const config = node?.config || {};
1355
+ const keys = pickInlineFieldKeys(typeInfo, node, maxFields);
1356
+ return keys
1357
+ .map((key) => {
1358
+ const fieldSchema = schema[key] || {};
1359
+ const value = config[key] ?? fieldSchema.default ?? "";
1360
+ const type = fieldSchema.type || "string";
1361
+ const isEnum = Array.isArray(fieldSchema.enum) && fieldSchema.enum.length > 0;
1362
+ const shortString = type === "string" && String(value || "").length <= 42;
1363
+ const supported = isEnum || type === "boolean" || type === "number" || shortString;
1364
+ if (!supported) return null;
1365
+ return {
1366
+ key,
1367
+ value: sanitizeInlineFieldValue(value),
1368
+ schema: fieldSchema,
1369
+ fieldType: type,
1370
+ isEnum,
1371
+ };
1372
+ })
1373
+ .filter(Boolean)
1374
+ .slice(0, maxFields);
1375
+ }
1376
+
1341
1377
  /* ═══════════════════════════════════════════════════════════════
1342
1378
  * Canvas — SVG-based Workflow Editor
1343
1379
  * ═══════════════════════════════════════════════════════════════ */
@@ -1359,6 +1395,8 @@ function WorkflowCanvas({ workflow, onSave, nodeTypes: availableNodeTypes = [] }
1359
1395
  const [pan, setPan] = useState({ x: 0, y: 0 });
1360
1396
  const [contextMenu, setContextMenu] = useState(null);
1361
1397
  const [spacePanning, setSpacePanning] = useState(false);
1398
+ const [connectionHint, setConnectionHint] = useState(null);
1399
+ const [portHoverHint, setPortHoverHint] = useState(null);
1362
1400
  const [selectedNodeIds, setSelectedNodeIds] = useState(new Set());
1363
1401
  const [historyState, setHistoryState] = useState(() => createHistoryState(workflow?.nodes || [], workflow?.edges || []));
1364
1402
  const [marquee, setMarquee] = useState(null);
@@ -1385,11 +1423,22 @@ function WorkflowCanvas({ workflow, onSave, nodeTypes: availableNodeTypes = [] }
1385
1423
  () => serializeGraphSnapshot(workflow?.nodes || [], workflow?.edges || []),
1386
1424
  [workflow?.nodes, workflow?.edges],
1387
1425
  );
1388
- const hasLiveStatuses = Object.keys(liveNodeStatuses).length > 0;
1389
- const liveRunDuration = liveRun?.status === "running" && liveRun?.startedAt
1390
- ? Math.max(0, liveNowTick - Number(liveRun.startedAt))
1391
- : Number(liveRun?.duration) || 0;
1392
- const liveActiveNodes = Object.values(liveNodeStatuses).filter((status) => status === "running").length;
1426
+ const nodeTypeMap = useMemo(
1427
+ () => new Map((availableNodeTypes || []).map((type) => [type.type, type])),
1428
+ [availableNodeTypes],
1429
+ );
1430
+ const ensureNodePortMetadata = useCallback((node) => {
1431
+ const ports = resolveNodePorts(node, nodeTypeMap);
1432
+ return {
1433
+ ...node,
1434
+ inputPorts: ports.inputs,
1435
+ outputPorts: ports.outputs,
1436
+ };
1437
+ }, [nodeTypeMap]);
1438
+
1439
+ const normalizeNodesForCanvas = useCallback((nodeList = []) => (
1440
+ (Array.isArray(nodeList) ? nodeList : []).map((node) => ensureNodePortMetadata(node))
1441
+ ), [ensureNodePortMetadata]);
1393
1442
  useEffect(() => { selectedNodeIdsRef.current = selectedNodeIds; }, [selectedNodeIds]);
1394
1443
  useEffect(() => {
1395
1444
  nodesRef.current = nodes;
@@ -1397,7 +1446,7 @@ function WorkflowCanvas({ workflow, onSave, nodeTypes: availableNodeTypes = [] }
1397
1446
  }, [nodes, edges]);
1398
1447
 
1399
1448
  useEffect(() => {
1400
- const nextNodes = workflow?.nodes || [];
1449
+ const nextNodes = normalizeNodesForCanvas(workflow?.nodes || []);
1401
1450
  const nextEdges = workflow?.edges || [];
1402
1451
  if (historyTimerRef.current) clearTimeout(historyTimerRef.current);
1403
1452
  historyPendingSnapshotRef.current = null;
@@ -1414,13 +1463,7 @@ function WorkflowCanvas({ workflow, onSave, nodeTypes: availableNodeTypes = [] }
1414
1463
  setEditingNode(null);
1415
1464
  setContextMenu(null);
1416
1465
  setShowNodePalette(false);
1417
- setLiveRun(null);
1418
- setLiveNodeStatuses({});
1419
- setLiveNodeOutputPreviews({});
1420
- setLiveNodeFlashStates({});
1421
- setLiveNodeRunningHints({});
1422
- setLiveEdgeActivity({});
1423
- }, [workflow?.id, workflowSnapshotKey]);
1466
+ }, [workflow?.id, workflowSnapshotKey, normalizeNodesForCanvas]);
1424
1467
 
1425
1468
  useEffect(() => {
1426
1469
  if (!liveHighlightEnabled || !workflow?.id) {
@@ -1699,11 +1742,8 @@ function WorkflowCanvas({ workflow, onSave, nodeTypes: availableNodeTypes = [] }
1699
1742
 
1700
1743
  // Canvas dimensions
1701
1744
  const NODE_W = 220;
1702
- const NODE_H = 138;
1703
- const NODE_HEADER_H = 62;
1745
+ const NODE_H = 118;
1704
1746
  const PORT_R = 8;
1705
- const HISTORY_LIMIT = 50;
1706
- const HISTORY_COMMIT_DEBOUNCE_MS = 220;
1707
1747
 
1708
1748
  const toCanvas = useCallback((clientX, clientY) => {
1709
1749
  const rect = canvasRef.current?.getBoundingClientRect();
@@ -1714,6 +1754,7 @@ function WorkflowCanvas({ workflow, onSave, nodeTypes: availableNodeTypes = [] }
1714
1754
  };
1715
1755
  }, [zoom, pan]);
1716
1756
 
1757
+
1717
1758
  const setHistory = useCallback((nextHistory) => {
1718
1759
  historyRef.current = nextHistory;
1719
1760
  setHistoryState(nextHistory);
@@ -1744,20 +1785,20 @@ function WorkflowCanvas({ workflow, onSave, nodeTypes: availableNodeTypes = [] }
1744
1785
 
1745
1786
  const scheduleSave = useCallback((nextNodes, nextEdges) => {
1746
1787
  if (saveTimer.current) clearTimeout(saveTimer.current);
1747
- const snapshot = serializeGraphSnapshot(nextNodes, nextEdges);
1788
+ const snapshot = serializeGraphSnapshot(normalizeNodesForCanvas(nextNodes), nextEdges);
1748
1789
  saveTimer.current = setTimeout(() => {
1749
1790
  if (!workflow?.id) return;
1750
1791
  const latest = parseGraphSnapshot(snapshot);
1751
- saveWorkflow({ ...workflow, nodes: latest.nodes, edges: latest.edges });
1792
+ saveWorkflow({ ...workflow, nodes: normalizeNodesForCanvas(latest.nodes), edges: latest.edges });
1752
1793
  }, 1500);
1753
- }, [workflow]);
1794
+ }, [normalizeNodesForCanvas, workflow]);
1754
1795
 
1755
1796
  const applyGraphChange = useCallback((updater, options = {}) => {
1756
1797
  const currentNodes = nodesRef.current;
1757
1798
  const currentEdges = edgesRef.current;
1758
1799
  const nextGraph = updater({ nodes: currentNodes, edges: currentEdges });
1759
1800
  if (!nextGraph) return null;
1760
- const nextNodes = nextGraph.nodes ?? currentNodes;
1801
+ const nextNodes = normalizeNodesForCanvas(nextGraph.nodes ?? currentNodes);
1761
1802
  const nextEdges = nextGraph.edges ?? currentEdges;
1762
1803
  if (nextNodes === currentNodes && nextEdges === currentEdges) return null;
1763
1804
  nodesRef.current = nextNodes;
@@ -1773,7 +1814,7 @@ function WorkflowCanvas({ workflow, onSave, nodeTypes: availableNodeTypes = [] }
1773
1814
  if (nextHistory !== historyRef.current) setHistory(nextHistory);
1774
1815
  }
1775
1816
  return { nodes: nextNodes, edges: nextEdges };
1776
- }, [flushPendingHistory, scheduleHistoryCommit, scheduleSave, setHistory]);
1817
+ }, [flushPendingHistory, normalizeNodesForCanvas, scheduleHistoryCommit, scheduleSave, setHistory]);
1777
1818
 
1778
1819
  const getDefaultInsertPoint = useCallback(() => {
1779
1820
  if ((mousePos.x || mousePos.y) && Number.isFinite(mousePos.x) && Number.isFinite(mousePos.y)) {
@@ -1797,7 +1838,7 @@ function WorkflowCanvas({ workflow, onSave, nodeTypes: availableNodeTypes = [] }
1797
1838
  }, []);
1798
1839
 
1799
1840
  const applyHistorySnapshot = useCallback((snapshot) => {
1800
- const nextNodes = snapshot?.nodes || [];
1841
+ const nextNodes = normalizeNodesForCanvas(snapshot?.nodes || []);
1801
1842
  const nextEdges = snapshot?.edges || [];
1802
1843
  if (historyTimerRef.current) clearTimeout(historyTimerRef.current);
1803
1844
  historyPendingSnapshotRef.current = null;
@@ -1811,7 +1852,7 @@ function WorkflowCanvas({ workflow, onSave, nodeTypes: availableNodeTypes = [] }
1811
1852
  setEditingNode(null);
1812
1853
  setContextMenu(null);
1813
1854
  scheduleSave(nextNodes, nextEdges);
1814
- }, [scheduleSave]);
1855
+ }, [normalizeNodesForCanvas, scheduleSave]);
1815
1856
 
1816
1857
  const undoCanvas = useCallback(() => {
1817
1858
  const readyHistory = flushPendingHistory();
@@ -2085,6 +2126,16 @@ function WorkflowCanvas({ workflow, onSave, nodeTypes: availableNodeTypes = [] }
2085
2126
  setZoom(z => Math.max(0.2, Math.min(3, z + delta)));
2086
2127
  }, []);
2087
2128
 
2129
+ const onCanvasDoubleClick = useCallback((e) => {
2130
+ const target = e.target;
2131
+ const isBackgroundTarget =
2132
+ target === e.currentTarget ||
2133
+ target?.classList?.contains?.("canvas-bg");
2134
+ if (!isBackgroundTarget) return;
2135
+ e.preventDefault();
2136
+ openNodePalette(toCanvas(e.clientX, e.clientY));
2137
+ }, [openNodePalette, toCanvas]);
2138
+
2088
2139
  // ── Node interaction ──────────────────────────────────────
2089
2140
 
2090
2141
  const onNodeMouseDown = useCallback((nodeId, e) => {
@@ -2164,26 +2215,95 @@ function WorkflowCanvas({ workflow, onSave, nodeTypes: availableNodeTypes = [] }
2164
2215
 
2165
2216
  // ── Port / connection interaction ─────────────────────────
2166
2217
 
2167
- const onOutputPortMouseDown = useCallback((nodeId, e) => {
2168
- e.stopPropagation();
2169
- setConnecting({ sourceId: nodeId, startX: e.clientX, startY: e.clientY });
2218
+ const showConnectionHint = useCallback((message, clientX, clientY) => {
2219
+ setConnectionHint({
2220
+ message,
2221
+ x: Math.max(12, Math.round(clientX || 0) + 12),
2222
+ y: Math.max(12, Math.round(clientY || 0) + 12),
2223
+ expiresAt: Date.now() + 2200,
2224
+ });
2170
2225
  }, []);
2171
2226
 
2172
- const onOutputPortPointerDown = useCallback((nodeId, e) => {
2227
+ const showPortHoverHint = useCallback((port, clientX, clientY) => {
2228
+ if (!port) {
2229
+ setPortHoverHint(null);
2230
+ return;
2231
+ }
2232
+ const type = String(port.type || "Any").trim() || "Any";
2233
+ const description = String(port.description || "").trim();
2234
+ const label = String(port.label || port.name || "Port").trim() || "Port";
2235
+ setPortHoverHint({
2236
+ message: `${label} (${type})${description ? ` - ${description}` : ""}`,
2237
+ x: Math.max(12, Math.round(clientX || 0) + 12),
2238
+ y: Math.max(12, Math.round(clientY || 0) + 12),
2239
+ });
2240
+ }, []);
2241
+
2242
+ const getNodeById = useCallback((nodeId) => nodesRef.current.find((node) => node.id === nodeId) || null, []);
2243
+
2244
+ const getOutputPortDescriptor = useCallback((nodeId, portName = "default") => {
2245
+ const node = getNodeById(nodeId);
2246
+ if (!node) return null;
2247
+ const ports = resolveNodePorts(node, nodeTypeMap).outputs;
2248
+ return ports.find((port) => port.name === portName) || ports[0] || null;
2249
+ }, [getNodeById, nodeTypeMap]);
2250
+
2251
+ const getInputPortDescriptor = useCallback((nodeId, portName = "default") => {
2252
+ const node = getNodeById(nodeId);
2253
+ if (!node) return null;
2254
+ const ports = resolveNodePorts(node, nodeTypeMap).inputs;
2255
+ return ports.find((port) => port.name === portName) || ports[0] || null;
2256
+ }, [getNodeById, nodeTypeMap]);
2257
+
2258
+ const onOutputPortMouseDown = useCallback((nodeId, portName, e) => {
2259
+ e.stopPropagation();
2260
+ const sourcePort = getOutputPortDescriptor(nodeId, portName);
2261
+ setConnecting({
2262
+ sourceId: nodeId,
2263
+ sourcePort: sourcePort?.name || portName || "default",
2264
+ startX: e.clientX,
2265
+ startY: e.clientY,
2266
+ });
2267
+ }, [getOutputPortDescriptor]);
2268
+
2269
+ const onOutputPortPointerDown = useCallback((nodeId, portName, e) => {
2173
2270
  if (e.pointerType !== "touch" && e.pointerType !== "pen") return;
2174
2271
  e.stopPropagation();
2175
- setConnecting({ sourceId: nodeId, startX: e.clientX, startY: e.clientY });
2272
+ const sourcePort = getOutputPortDescriptor(nodeId, portName);
2273
+ setConnecting({
2274
+ sourceId: nodeId,
2275
+ sourcePort: sourcePort?.name || portName || "default",
2276
+ startX: e.clientX,
2277
+ startY: e.clientY,
2278
+ });
2176
2279
  movePointer(e.clientX, e.clientY);
2177
2280
  try {
2178
2281
  canvasRef.current?.setPointerCapture?.(e.pointerId);
2179
2282
  } catch {}
2180
2283
  e.preventDefault();
2181
- }, [movePointer]);
2284
+ }, [getOutputPortDescriptor, movePointer]);
2182
2285
 
2183
- const onInputPortMouseUp = useCallback((nodeId) => {
2286
+ const onInputPortMouseUp = useCallback((nodeId, targetPortName = "default", eventMeta = null) => {
2184
2287
  if (connecting && connecting.sourceId !== nodeId) {
2185
- const edgeId = `${connecting.sourceId}->${nodeId}`;
2186
- const exists = edgesRef.current.some((edge) => edge.source === connecting.sourceId && edge.target === nodeId);
2288
+ const sourcePort = getOutputPortDescriptor(connecting.sourceId, connecting.sourcePort || "default");
2289
+ const targetPort = getInputPortDescriptor(nodeId, targetPortName);
2290
+ const compatibility = isPortConnectionCompatible(sourcePort, targetPort);
2291
+ if (!compatibility.compatible) {
2292
+ showConnectionHint(
2293
+ compatibility.reason || "Incompatible port types",
2294
+ eventMeta?.clientX || mousePos.x,
2295
+ eventMeta?.clientY || mousePos.y,
2296
+ );
2297
+ setConnecting(null);
2298
+ return;
2299
+ }
2300
+ const edgeId = `${connecting.sourceId}:${sourcePort?.name || "default"}->${nodeId}:${targetPort?.name || "default"}`;
2301
+ const exists = edgesRef.current.some((edge) =>
2302
+ edge.source === connecting.sourceId
2303
+ && edge.target === nodeId
2304
+ && String(edge.sourcePort || "default") === String(sourcePort?.name || "default")
2305
+ && String(edge.targetPort || "default") === String(targetPort?.name || "default")
2306
+ );
2187
2307
  if (!exists) {
2188
2308
  applyGraphChange(({ nodes: currentNodes, edges: currentEdges }) => ({
2189
2309
  nodes: currentNodes,
@@ -2191,18 +2311,21 @@ function WorkflowCanvas({ workflow, onSave, nodeTypes: availableNodeTypes = [] }
2191
2311
  id: edgeId,
2192
2312
  source: connecting.sourceId,
2193
2313
  target: nodeId,
2194
- sourcePort: "default",
2314
+ sourcePort: sourcePort?.name || "default",
2315
+ targetPort: targetPort?.name || "default",
2316
+ sourcePortType: sourcePort?.type || "Any",
2317
+ targetPortType: targetPort?.type || "Any",
2195
2318
  }],
2196
2319
  }));
2197
2320
  }
2198
2321
  }
2199
2322
  setConnecting(null);
2200
- }, [applyGraphChange, connecting]);
2323
+ }, [applyGraphChange, connecting, getInputPortDescriptor, getOutputPortDescriptor, mousePos.x, mousePos.y, showConnectionHint]);
2201
2324
 
2202
- const onInputPortPointerUp = useCallback((nodeId, e) => {
2325
+ const onInputPortPointerUp = useCallback((nodeId, portName, e) => {
2203
2326
  if (e.pointerType !== "touch" && e.pointerType !== "pen") return;
2204
2327
  e.stopPropagation();
2205
- onInputPortMouseUp(nodeId);
2328
+ onInputPortMouseUp(nodeId, portName, { clientX: e.clientX, clientY: e.clientY });
2206
2329
  e.preventDefault();
2207
2330
  }, [onInputPortMouseUp]);
2208
2331
 
@@ -2211,12 +2334,23 @@ function WorkflowCanvas({ workflow, onSave, nodeTypes: availableNodeTypes = [] }
2211
2334
  const addNode = useCallback((type, position = paletteInsertPoint || getDefaultInsertPoint()) => {
2212
2335
  const id = `node-${Date.now()}-${Math.round(Math.random() * 1000)}`;
2213
2336
  const name = type.split(".").pop();
2337
+ const typeInfo = nodeTypeMap.get(type) || null;
2338
+ const nextConfig = {};
2339
+ const schemaProps = typeInfo?.schema?.properties || {};
2340
+ for (const [key, field] of Object.entries(schemaProps)) {
2341
+ if (Object.prototype.hasOwnProperty.call(field || {}, "default")) {
2342
+ nextConfig[key] = field.default;
2343
+ }
2344
+ }
2345
+ const ports = resolveNodePorts({ type }, nodeTypeMap);
2214
2346
  const newNode = {
2215
2347
  id,
2216
2348
  type,
2217
2349
  label: name?.replace(/_/g, " ") || type,
2218
- config: {},
2350
+ config: nextConfig,
2219
2351
  position: position || { x: 300, y: 300 },
2352
+ inputPorts: ports.inputs,
2353
+ outputPorts: ports.outputs,
2220
2354
  outputs: ["default"],
2221
2355
  };
2222
2356
  applyGraphChange(({ nodes: currentNodes, edges: currentEdges }) => ({
@@ -2228,7 +2362,7 @@ function WorkflowCanvas({ workflow, onSave, nodeTypes: availableNodeTypes = [] }
2228
2362
  setSelectedNodeIds(new Set([id]));
2229
2363
  closeNodePalette();
2230
2364
  haptic("light");
2231
- }, [applyGraphChange, closeNodePalette, getDefaultInsertPoint, paletteInsertPoint]);
2365
+ }, [applyGraphChange, closeNodePalette, getDefaultInsertPoint, nodeTypeMap, paletteInsertPoint]);
2232
2366
 
2233
2367
  const deleteNode = useCallback((nodeId) => {
2234
2368
  applyGraphChange(({ nodes: currentNodes, edges: currentEdges }) => ({
@@ -2297,24 +2431,44 @@ function WorkflowCanvas({ workflow, onSave, nodeTypes: availableNodeTypes = [] }
2297
2431
  if (historyTimerRef.current) clearTimeout(historyTimerRef.current);
2298
2432
  }, []);
2299
2433
 
2434
+ useEffect(() => {
2435
+ if (!connectionHint) return undefined;
2436
+ const remaining = Math.max(120, (connectionHint.expiresAt || Date.now() + 1200) - Date.now());
2437
+ const timer = setTimeout(() => {
2438
+ setConnectionHint((current) => (current === connectionHint ? null : current));
2439
+ }, remaining);
2440
+ return () => clearTimeout(timer);
2441
+ }, [connectionHint]);
2442
+
2443
+ useEffect(() => {
2444
+ if (connecting) return undefined;
2445
+ setPortHoverHint(null);
2446
+ return undefined;
2447
+ }, [connecting]);
2448
+
2300
2449
  // ── Render helpers ────────────────────────────────────────
2301
2450
 
2302
2451
  const getNodeCenter = (nodeId) => {
2303
- const n = nodes.find(n => n.id === nodeId);
2452
+ const n = nodes.find((value) => value.id === nodeId);
2304
2453
  if (!n) return { x: 0, y: 0 };
2305
2454
  return { x: (n.position?.x || 0) + NODE_W / 2, y: (n.position?.y || 0) + NODE_H / 2 };
2306
2455
  };
2307
2456
 
2308
- const getInputPort = (nodeId) => {
2309
- const n = nodes.find(n => n.id === nodeId);
2457
+ const getNodePortPosition = (nodeId, direction, portName = "default") => {
2458
+ const n = nodes.find((value) => value.id === nodeId);
2310
2459
  if (!n) return { x: 0, y: 0 };
2311
- return { x: (n.position?.x || 0), y: (n.position?.y || 0) + NODE_H / 2 };
2312
- };
2313
-
2314
- const getOutputPort = (nodeId) => {
2315
- const n = nodes.find(n => n.id === nodeId);
2316
- if (!n) return { x: 0, y: 0 };
2317
- return { x: (n.position?.x || 0) + NODE_W, y: (n.position?.y || 0) + NODE_H / 2 };
2460
+ const ports = resolveNodePorts(n, nodeTypeMap)[direction === "input" ? "inputs" : "outputs"];
2461
+ const index = Math.max(
2462
+ 0,
2463
+ ports.findIndex((port) => port.name === portName),
2464
+ );
2465
+ const spread = 24;
2466
+ const centerY = NODE_H / 2 + 10;
2467
+ const offsetY = (index - ((ports.length - 1) / 2)) * spread;
2468
+ return {
2469
+ x: (n.position?.x || 0) + (direction === "input" ? 0 : NODE_W),
2470
+ y: (n.position?.y || 0) + centerY + offsetY,
2471
+ };
2318
2472
  };
2319
2473
 
2320
2474
  // Bezier curve between points
@@ -2340,7 +2494,7 @@ function WorkflowCanvas({ workflow, onSave, nodeTypes: availableNodeTypes = [] }
2340
2494
  <${Button} variant="contained" size="small" onClick=${() => openNodePalette()} sx=${{ display: 'flex', alignItems: 'center', gap: '6px' }}>
2341
2495
  <span style="font-size: 18px;">+</span> Add Node /
2342
2496
  <//>
2343
- <${Button} variant="outlined" size="small" onClick=${() => { if (workflow) saveWorkflow({ ...workflow, nodes: nodesRef.current, edges: edgesRef.current }); }}>
2497
+ <${Button} variant="outlined" size="small" onClick=${() => { if (workflow) saveWorkflow({ ...workflow, nodes: normalizeNodesForCanvas(nodesRef.current), edges: edgesRef.current }); }}>
2344
2498
  <span class="btn-icon">${resolveIcon("save")}</span>
2345
2499
  Save
2346
2500
  <//>
@@ -2439,6 +2593,7 @@ function WorkflowCanvas({ workflow, onSave, nodeTypes: availableNodeTypes = [] }
2439
2593
  onPointerUp=${onPointerUp}
2440
2594
  onPointerCancel=${onPointerUp}
2441
2595
  onWheel=${onWheel}
2596
+ onDblClick=${onCanvasDoubleClick}
2442
2597
  onContextMenu=${(e) => e.preventDefault()}
2443
2598
  >
2444
2599
  <defs>
@@ -2457,27 +2612,25 @@ function WorkflowCanvas({ workflow, onSave, nodeTypes: availableNodeTypes = [] }
2457
2612
  </defs>
2458
2613
 
2459
2614
  <!-- Background grid — covers entire pannable area -->
2460
- <rect class="canvas-bg" x="-10000" y="-10000" width="30000" height="30000" fill="url(#grid-pattern)" onDblClick=${(e) => { e.preventDefault(); openNodePalette(toCanvas(e.clientX, e.clientY)); }} />
2615
+ <rect class="canvas-bg" x="-10000" y="-10000" width="30000" height="30000" fill="url(#grid-pattern)" />
2461
2616
 
2462
2617
  <g transform="translate(${pan.x} ${pan.y}) scale(${zoom})">
2463
2618
 
2464
2619
  <!-- Edges -->
2465
2620
  ${edges.map(edge => {
2466
- const from = getOutputPort(edge.source);
2467
- const to = getInputPort(edge.target);
2621
+ const sourcePort = getOutputPortDescriptor(edge.source, edge.sourcePort || "default");
2622
+ const from = getNodePortPosition(edge.source, "output", edge.sourcePort || "default");
2623
+ const to = getNodePortPosition(edge.target, "input", edge.targetPort || "default");
2468
2624
  const isSelected = selectedEdgeId.value === edge.id;
2469
2625
  const hasCondition = !!edge.condition;
2470
- const edgeKey = edge.id || `${edge.source}->${edge.target}`;
2471
- const edgeActivity = liveHighlightEnabled ? liveEdgeActivity[edgeKey] : null;
2472
- const isActiveFlow = Boolean(edgeActivity);
2473
- const edgePath = curvePath(from.x, from.y, to.x, to.y);
2626
+ const edgeColor = sourcePort?.color || (hasCondition ? "#f59e0b" : "#6b7280");
2474
2627
  return html`
2475
2628
  <g key=${edge.id} class="wf-edge" onClick=${(e) => { e.stopPropagation(); selectedEdgeId.value = edge.id; }}>
2476
2629
  <path
2477
2630
  d=${edgePath}
2478
2631
  fill="none"
2479
- stroke=${isSelected ? "#3b82f6" : isActiveFlow ? "#60a5fa" : hasCondition ? "#f59e0b" : "#6b7280"}
2480
- stroke-width=${isSelected ? 3 : isActiveFlow ? 2.8 : 2}
2632
+ stroke=${isSelected ? "#3b82f6" : edgeColor}
2633
+ stroke-width=${isSelected ? 3 : 2}
2481
2634
  stroke-dasharray=${hasCondition ? "6,4" : "none"}
2482
2635
  marker-end="url(#arrowhead)"
2483
2636
  style=${`cursor: pointer; transition: stroke 0.15s, stroke-width 0.15s; ${isActiveFlow ? "filter: drop-shadow(0 0 6px rgba(96,165,250,0.45));" : ""}`}
@@ -2536,20 +2689,29 @@ function WorkflowCanvas({ workflow, onSave, nodeTypes: availableNodeTypes = [] }
2536
2689
 
2537
2690
  <!-- Connecting line (while dragging) -->
2538
2691
  ${connecting && html`
2692
+ ${(() => {
2693
+ const start = getNodePortPosition(connecting.sourceId, "output", connecting.sourcePort || "default");
2694
+ const sourcePort = getOutputPortDescriptor(connecting.sourceId, connecting.sourcePort || "default");
2695
+ return html`
2539
2696
  <line
2540
- x1=${getOutputPort(connecting.sourceId).x}
2541
- y1=${getOutputPort(connecting.sourceId).y}
2697
+ x1=${start.x}
2698
+ y1=${start.y}
2542
2699
  x2=${mousePos.x}
2543
2700
  y2=${mousePos.y}
2544
- stroke="#3b82f680"
2701
+ stroke=${(sourcePort?.color || "#3b82f6") + "80"}
2545
2702
  stroke-width="2"
2546
2703
  stroke-dasharray="6,4"
2547
2704
  />
2705
+ `;
2706
+ })()}
2548
2707
  `}
2549
2708
 
2550
2709
  <!-- Nodes -->
2551
2710
  ${nodes.map(node => {
2552
2711
  const meta = getNodeMeta(node.type);
2712
+ const typeInfo = nodeTypeMap.get(node.type) || null;
2713
+ const ports = resolveNodePorts(node, nodeTypeMap);
2714
+ const inlineFields = getInlineFieldDescriptors(typeInfo, node, 2);
2553
2715
  const isSelected = selectedNodeIds.has(node.id);
2554
2716
  const nodeRunStatus = liveHighlightEnabled ? normalizeLiveNodeStatus(liveNodeStatuses[node.id]) : null;
2555
2717
  const nodeFlash = liveNodeFlashStates[node.id] || null;
@@ -2615,7 +2777,7 @@ function WorkflowCanvas({ workflow, onSave, nodeTypes: availableNodeTypes = [] }
2615
2777
  <!-- Label -->
2616
2778
  <text
2617
2779
  x=${NODE_W / 2}
2618
- y="22"
2780
+ y="24"
2619
2781
  text-anchor="middle"
2620
2782
  fill="white"
2621
2783
  font-size="13"
@@ -2631,92 +2793,129 @@ function WorkflowCanvas({ workflow, onSave, nodeTypes: availableNodeTypes = [] }
2631
2793
  font-size="10"
2632
2794
  >${node.type}</text>
2633
2795
 
2634
- ${nodeRunStatus && html`
2635
- <g transform="translate(${NODE_W - 74} 8)">
2636
- <rect
2637
- width="66"
2638
- height="18"
2639
- rx="9"
2640
- fill=${nodeStatusStyles.bg}
2641
- stroke=${nodeStatusStyles.color}
2642
- stroke-opacity="0.4"
2643
- />
2644
- <text
2645
- x="33"
2646
- y="12"
2647
- text-anchor="middle"
2648
- fill=${nodeStatusStyles.color}
2649
- font-size="10"
2650
- font-weight="700"
2651
- >${toNodeStatusLabel(nodeRunStatus)}</text>
2652
- </g>
2653
- `}
2654
-
2655
- ${spinnerVisible && html`
2656
- <g transform="translate(${NODE_W - 22} ${NODE_HEADER_H - 18})">
2657
- <circle r="9" cx="0" cy="0" fill="rgba(15, 23, 42, 0.72)" stroke="#93c5fd44" stroke-width="1.2" />
2658
- <circle r="6.8" cx="0" cy="0" fill="none" stroke="#93c5fd33" stroke-width="1.2" />
2659
- <path d="M 0 -6.8 A 6.8 6.8 0 0 1 5.4 -4.2" fill="none" stroke="#93c5fd" stroke-width="1.8" stroke-linecap="round">
2660
- <animateTransform attributeName="transform" type="rotate" from="0 0 0" to="360 0 0" dur="0.8s" repeatCount="indefinite" />
2661
- </path>
2662
- </g>
2663
- `}
2664
-
2665
- <line x1="8" y1=${NODE_HEADER_H} x2=${NODE_W - 8} y2=${NODE_HEADER_H} stroke="#2e3748" stroke-width="1" opacity="0.85" />
2666
- <rect
2667
- x="8"
2668
- y=${previewPanelY}
2669
- width=${NODE_W - 16}
2670
- height=${previewPanelH}
2671
- rx="6"
2672
- fill="rgba(15, 23, 42, 0.45)"
2673
- stroke=${hasPreview ? "rgba(148, 163, 184, 0.28)" : "rgba(71, 85, 105, 0.2)"}
2674
- stroke-width="1"
2675
- />
2676
-
2677
- ${hasPreview && html`
2678
- <text x="14" y=${previewPanelY + 14} fill="#cbd5e1" font-size="10" font-family="ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace">
2679
- ${previewLines.map((line, index) => html`
2680
- <tspan x="14" dy=${index === 0 ? 0 : 13}>${line.slice(0, 44)}</tspan>
2681
- `)}
2682
- </text>
2683
- `}
2684
- ${preview.tokenCount != null && html`
2685
- <text
2686
- x=${NODE_W - 14}
2687
- y=${NODE_H - 10}
2688
- text-anchor="end"
2689
- fill="#93c5fd"
2690
- font-size="10"
2691
- font-family="ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace"
2692
- >${preview.tokenCount} tok</text>
2796
+ ${inlineFields.length > 0 && html`
2797
+ <foreignObject
2798
+ x="10"
2799
+ y="48"
2800
+ width=${NODE_W - 20}
2801
+ height="56"
2802
+ style="overflow: visible;"
2803
+ onMouseDown=${(e) => e.stopPropagation()}
2804
+ onPointerDown=${(e) => e.stopPropagation()}
2805
+ >
2806
+ <div xmlns="http://www.w3.org/1999/xhtml" style="display:flex; flex-direction:column; gap:4px; font-size:10px;">
2807
+ ${inlineFields.map((field) => {
2808
+ const label = String(field.key || "").replace(/([A-Z])/g, " $1").replace(/_/g, " ").trim();
2809
+ if (field.isEnum) {
2810
+ return html`
2811
+ <label key=${field.key} style="display:flex; flex-direction:column; gap:2px; color:#94a3b8;">
2812
+ <span>${label}</span>
2813
+ <select
2814
+ value=${field.value ?? ""}
2815
+ style="height:18px; border:1px solid #334155; border-radius:4px; background:#0f172a; color:#e2e8f0; font-size:10px;"
2816
+ onInput=${(e) => updateNodeConfig(node.id, { [field.key]: e.target.value })}
2817
+ onMouseDown=${(e) => e.stopPropagation()}
2818
+ >
2819
+ <option value="">-</option>
2820
+ ${(field.schema.enum || []).map((opt) => html`<option key=${String(opt)} value=${opt}>${String(opt)}</option>`)}
2821
+ </select>
2822
+ </label>
2823
+ `;
2824
+ }
2825
+ if (field.fieldType === "boolean") {
2826
+ return html`
2827
+ <label key=${field.key} style="display:flex; align-items:center; gap:6px; color:#94a3b8;">
2828
+ <input
2829
+ type="checkbox"
2830
+ checked=${Boolean(field.value)}
2831
+ onInput=${(e) => updateNodeConfig(node.id, { [field.key]: e.target.checked })}
2832
+ onMouseDown=${(e) => e.stopPropagation()}
2833
+ />
2834
+ <span>${label}</span>
2835
+ </label>
2836
+ `;
2837
+ }
2838
+ return html`
2839
+ <label key=${field.key} style="display:flex; flex-direction:column; gap:2px; color:#94a3b8;">
2840
+ <span>${label}</span>
2841
+ <input
2842
+ type=${field.fieldType === "number" ? "number" : "text"}
2843
+ value=${field.value ?? ""}
2844
+ style="height:18px; border:1px solid #334155; border-radius:4px; background:#0f172a; color:#e2e8f0; font-size:10px; padding:0 4px;"
2845
+ onInput=${(e) => updateNodeConfig(node.id, {
2846
+ [field.key]: field.fieldType === "number" ? Number(e.target.value || 0) : e.target.value,
2847
+ })}
2848
+ onMouseDown=${(e) => e.stopPropagation()}
2849
+ />
2850
+ </label>
2851
+ `;
2852
+ })}
2853
+ </div>
2854
+ </foreignObject>
2693
2855
  `}
2694
2856
 
2695
- <!-- Input port (left) -->
2696
- <circle
2697
- cx="0"
2698
- cy=${NODE_H / 2}
2699
- r=${PORT_R}
2700
- fill="#1a1f2e"
2701
- stroke=${connecting ? "#10b981" : "#4a5568"}
2702
- stroke-width="2"
2703
- style="cursor: crosshair;"
2704
- onMouseUp=${() => onInputPortMouseUp(node.id)}
2705
- onPointerUp=${(e) => onInputPortPointerUp(node.id, e)}
2706
- />
2707
-
2708
- <!-- Output port (right) -->
2709
- <circle
2710
- cx=${NODE_W}
2711
- cy=${NODE_H / 2}
2712
- r=${PORT_R}
2713
- fill="#1a1f2e"
2714
- stroke=${meta.color}
2715
- stroke-width="2"
2716
- style="cursor: crosshair;"
2717
- onMouseDown=${(e) => onOutputPortMouseDown(node.id, e)}
2718
- onPointerDown=${(e) => onOutputPortPointerDown(node.id, e)}
2719
- />
2857
+ ${ports.inputs.map((port) => {
2858
+ const pos = getNodePortPosition(node.id, "input", port.name);
2859
+ const localY = pos.y - y;
2860
+ const sourcePort = connecting ? getOutputPortDescriptor(connecting.sourceId, connecting.sourcePort || "default") : null;
2861
+ const compatibility = connecting && connecting.sourceId !== node.id
2862
+ ? isPortConnectionCompatible(sourcePort, port)
2863
+ : { compatible: true };
2864
+ const strokeColor = connecting && connecting.sourceId !== node.id
2865
+ ? (compatibility.compatible ? "#22c55e" : "#ef4444")
2866
+ : (port.color || "#4a5568");
2867
+ const cursorStyle = connecting && connecting.sourceId !== node.id && !compatibility.compatible
2868
+ ? "not-allowed"
2869
+ : "crosshair";
2870
+ return html`
2871
+ <circle
2872
+ key=${`in-${node.id}-${port.name}`}
2873
+ cx="0"
2874
+ cy=${localY}
2875
+ r=${PORT_R}
2876
+ fill="#0f172a"
2877
+ stroke=${strokeColor}
2878
+ stroke-width="2"
2879
+ style=${`cursor: ${cursorStyle};`}
2880
+ onMouseUp=${(e) => onInputPortMouseUp(node.id, port.name, { clientX: e.clientX, clientY: e.clientY })}
2881
+ onPointerUp=${(e) => onInputPortPointerUp(node.id, port.name, e)}
2882
+ onMouseEnter=${(e) => {
2883
+ showPortHoverHint(port, e.clientX, e.clientY);
2884
+ if (connecting && connecting.sourceId !== node.id && !compatibility.compatible) {
2885
+ showConnectionHint(compatibility.reason || "Incompatible port types", e.clientX, e.clientY);
2886
+ }
2887
+ }}
2888
+ onMouseMove=${(e) => showPortHoverHint(port, e.clientX, e.clientY)}
2889
+ onMouseLeave=${() => setPortHoverHint(null)}
2890
+ >
2891
+ <title>${`${port.label} (${port.type})${port.description ? ` - ${port.description}` : ""}`}</title>
2892
+ </circle>
2893
+ `;
2894
+ })}
2895
+
2896
+ ${ports.outputs.map((port) => {
2897
+ const pos = getNodePortPosition(node.id, "output", port.name);
2898
+ const localY = pos.y - y;
2899
+ return html`
2900
+ <circle
2901
+ key=${`out-${node.id}-${port.name}`}
2902
+ cx=${NODE_W}
2903
+ cy=${localY}
2904
+ r=${PORT_R}
2905
+ fill="#0f172a"
2906
+ stroke=${port.color || meta.color}
2907
+ stroke-width="2"
2908
+ style="cursor: crosshair;"
2909
+ onMouseDown=${(e) => onOutputPortMouseDown(node.id, port.name, e)}
2910
+ onPointerDown=${(e) => onOutputPortPointerDown(node.id, port.name, e)}
2911
+ onMouseEnter=${(e) => showPortHoverHint(port, e.clientX, e.clientY)}
2912
+ onMouseMove=${(e) => showPortHoverHint(port, e.clientX, e.clientY)}
2913
+ onMouseLeave=${() => setPortHoverHint(null)}
2914
+ >
2915
+ <title>${`${port.label} (${port.type})${port.description ? ` - ${port.description}` : ""}`}</title>
2916
+ </circle>
2917
+ `;
2918
+ })}
2720
2919
  </g>
2721
2920
  `;
2722
2921
  })}
@@ -2738,6 +2937,22 @@ function WorkflowCanvas({ workflow, onSave, nodeTypes: availableNodeTypes = [] }
2738
2937
  </g>
2739
2938
  </svg>
2740
2939
 
2940
+ ${connectionHint && html`
2941
+ <div
2942
+ style="position: fixed; left: ${connectionHint.x}px; top: ${connectionHint.y}px; z-index: 40; max-width: 320px; padding: 6px 8px; border-radius: 6px; background: #111827; color: #fca5a5; border: 1px solid #ef444480; font-size: 11px; pointer-events: none; box-shadow: 0 8px 24px rgba(0,0,0,0.35);"
2943
+ >
2944
+ ${connectionHint.message}
2945
+ </div>
2946
+ `}
2947
+
2948
+ ${portHoverHint && html`
2949
+ <div
2950
+ style="position: fixed; left: ${portHoverHint.x}px; top: ${portHoverHint.y}px; z-index: 39; max-width: 340px; padding: 6px 8px; border-radius: 6px; background: #0f172a; color: #cbd5e1; border: 1px solid #334155; font-size: 11px; pointer-events: none; box-shadow: 0 8px 24px rgba(0,0,0,0.35);"
2951
+ >
2952
+ ${portHoverHint.message}
2953
+ </div>
2954
+ `}
2955
+
2741
2956
  <!-- Context Menu -->
2742
2957
  ${contextMenu && html`
2743
2958
  <div class="wf-context-menu" style="position: fixed; left: ${contextMenu.x}px; top: ${contextMenu.y}px; z-index: 50;">
@@ -2758,14 +2973,22 @@ function WorkflowCanvas({ workflow, onSave, nodeTypes: availableNodeTypes = [] }
2758
2973
 
2759
2974
  <!-- Node Config Editor (side panel) -->
2760
2975
  ${editingNode && html`
2976
+ ${(() => {
2977
+ const editingNodeDef = nodes.find((n) => n.id === editingNode) || null;
2978
+ const editingTypeInfo = nodeTypeMap.get(editingNodeDef?.type) || null;
2979
+ const inlineDescriptors = getInlineFieldDescriptors(editingTypeInfo, editingNodeDef, 3);
2980
+ return html`
2761
2981
  <${NodeConfigEditor}
2762
- node=${nodes.find(n => n.id === editingNode)}
2763
- nodeTypes=${nodeTypes.value}
2982
+ node=${editingNodeDef}
2983
+ nodeTypes=${availableNodeTypes}
2984
+ inlineFieldKeys=${inlineDescriptors.map((field) => field.key)}
2764
2985
  onUpdate=${(config) => updateNodeConfig(editingNode, config)}
2765
2986
  onUpdateLabel=${(label) => updateNodeLabel(editingNode, label)}
2766
2987
  onClose=${() => setEditingNode(null)}
2767
2988
  onDelete=${() => deleteNode(editingNode)}
2768
2989
  />
2990
+ `;
2991
+ })()}
2769
2992
  `}
2770
2993
  </div>
2771
2994
  `;
@@ -2801,24 +3024,37 @@ function NodePalette({
2801
3024
  setSelectedIndex(0);
2802
3025
  }, [results.length, selectedIndex]);
2803
3026
 
3027
+ const renderChips = (items = [], fallback = "None") => {
3028
+ const safeList = Array.isArray(items) ? items : [];
3029
+ if (!safeList.length) {
3030
+ return html`<span class="wf-node-chip wf-node-chip-fallback">${fallback}</span>`;
3031
+ }
3032
+ const limit = 4;
3033
+ const visible = safeList.slice(0, limit);
3034
+ const remainder = Math.max(0, safeList.length - visible.length);
3035
+ return html`
3036
+ ${visible.map((value, index) => html`<span key=${`${value}-${index}`} class="wf-node-chip">${value}</span>`)}
3037
+ ${remainder > 0 && html`<span class="wf-node-chip wf-node-chip-more">+${remainder}</span>`}
3038
+ `;
3039
+ };
3040
+
2804
3041
  if (!open) return null;
2805
3042
 
3043
+ const totalTypes = types?.length || 0;
2806
3044
  const selected = results[selectedIndex] || results[0] || null;
2807
3045
  const submit = (item) => {
2808
3046
  if (!item) return;
2809
3047
  onSelect(item.type);
2810
3048
  };
3049
+ const pointLabel = `${Math.round(insertPoint?.x || 0)}, ${Math.round(insertPoint?.y || 0)}`;
2811
3050
 
2812
3051
  return html`
2813
- <div
2814
- style="position: absolute; inset: 0; z-index: 32; background: rgba(3, 7, 18, 0.55); backdrop-filter: blur(2px);"
2815
- onClick=${(e) => { if (e.target === e.currentTarget) onClose(); }}
2816
- >
2817
- <div class="wf-palette" style="position: absolute; top: 72px; left: 50%; transform: translateX(-50%); width: min(760px, calc(100% - 32px)); max-height: min(70vh, 720px); overflow: hidden; background: var(--color-bg, #0d1117); border: 1px solid var(--color-border, #2a3040); border-radius: 16px; padding: 14px; box-shadow: 0 18px 48px rgba(0,0,0,0.45); display: flex; flex-direction: column; gap: 12px;">
2818
- <div style="display: flex; align-items: center; gap: 10px;">
2819
- <div style="flex: 1; min-width: 0;">
2820
- <div style="font-size: 14px; font-weight: 700; color: var(--color-text, white);">Insert workflow node</div>
2821
- <div style="font-size: 11px; opacity: 0.7; margin-top: 3px;">${types?.length || 0} node types · insert at ${Math.round(insertPoint?.x || 0)}, ${Math.round(insertPoint?.y || 0)}</div>
3052
+ <div class="wf-palette-backdrop" onClick=${(e) => { if (e.target === e.currentTarget) onClose(); }}>
3053
+ <div class="wf-palette">
3054
+ <div class="wf-palette-header">
3055
+ <div class="wf-palette-title-group">
3056
+ <div class="wf-palette-title">Insert workflow node</div>
3057
+ <div class="wf-palette-subtitle">${totalTypes} node types · insert at ${pointLabel}</div>
2822
3058
  </div>
2823
3059
  <${IconButton} size="small" onClick=${onClose} sx=${{ fontSize: '16px', lineHeight: 1 }}>
2824
3060
  <span class="icon-inline">${resolveIcon("✕")}</span>
@@ -2854,45 +3090,64 @@ function NodePalette({
2854
3090
  sx=${{ flex: 1 }}
2855
3091
  autoFocus
2856
3092
  />
2857
- <div style="display: flex; align-items: center; gap: 8px; font-size: 11px; opacity: 0.7;">
3093
+ <div class="wf-palette-hints">
2858
3094
  <span>${results.length} matches</span>
2859
3095
  <span>·</span>
2860
3096
  <span>↵ insert</span>
2861
3097
  <span>·</span>
2862
3098
  <span>↑↓ navigate</span>
2863
3099
  </div>
2864
- <div style="display: flex; flex-direction: column; gap: 8px; overflow-y: auto; padding-right: 2px;">
3100
+ <div class="wf-palette-results">
2865
3101
  ${results.map((item, index) => {
2866
3102
  const meta = NODE_CATEGORY_META[item.category] || { color: "#6b7280", bg: "#6b728020", label: item.category };
2867
3103
  const io = getNodeSearchMetadata(item);
2868
- const inputText = io.inputs.length
2869
- ? `Inputs: ${io.inputs.slice(0, 4).join(", ")}${io.inputs.length > 4 ? ` +${io.inputs.length - 4}` : ""}`
2870
- : "Inputs: none";
2871
- const outputText = `Outputs: ${io.outputs.join(", ")}`;
2872
3104
  return html`
2873
3105
  <button
2874
3106
  key=${item.type}
2875
3107
  type="button"
2876
3108
  class=${`wf-node-search-item ${index === selectedIndex ? "active" : ""}`}
2877
- style=${`display:flex; flex-direction:column; gap:8px; width:100%; text-align:left; border:1px solid ${index === selectedIndex ? '#3b82f6aa' : 'var(--color-border, #2a3040)'}; border-radius:12px; padding:12px 14px; background:${index === selectedIndex ? 'rgba(59,130,246,0.12)' : 'var(--color-bg-secondary, #131722)'}; color:var(--color-text, white); cursor:pointer;`}
3109
+ style=${`border-color: ${index === selectedIndex ? '#3b82f6aa' : 'var(--color-border, #2a3040)'}; background: ${index === selectedIndex ? 'rgba(59,130,246,0.12)' : 'var(--color-bg-secondary, #131722)'};`}
2878
3110
  onMouseEnter=${() => setSelectedIndex(index)}
2879
3111
  onClick=${() => submit(item)}
2880
3112
  >
2881
- <div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap;">
2882
- <span style="font-weight:700; font-size:13px;">${item.label}</span>
2883
- <span style=${`display:inline-flex; align-items:center; padding:2px 8px; border-radius:999px; font-size:10px; letter-spacing:0.04em; text-transform:uppercase; color:${meta.color}; background:${meta.bg}; border:1px solid ${meta.color}33;`}>${meta.label || item.category}</span>
2884
- <span style="font-size:11px; opacity:0.55;">${item.type}</span>
3113
+ <div class="wf-node-search-item-top">
3114
+ <span class="wf-node-search-label">${item.label}</span>
3115
+ <span
3116
+ class="wf-node-category-badge"
3117
+ style=${`color:${meta.color}; background:${meta.bg}; border-color:${meta.color}33;`}
3118
+ >
3119
+ ${meta.label || item.category}
3120
+ </span>
3121
+ ${item.badge
3122
+ ? html`<span
3123
+ class="wf-node-category-badge"
3124
+ style=${`color:${item.isCustom ? "#f472b6" : "#cbd5e1"}; background:${item.isCustom ? "rgba(244,114,182,0.2)" : "rgba(148,163,184,0.2)"}; border-color:${item.isCustom ? "#f472b655" : "#94a3b855"};`}
3125
+ >
3126
+ ${item.badge}
3127
+ </span>`
3128
+ : ""}
3129
+ <span class="wf-node-search-type">${item.type}</span>
2885
3130
  </div>
2886
- <div style="font-size:12px; opacity:0.78; line-height:1.4;">${item.description || "No description available."}</div>
2887
- <div style="display:flex; gap:10px; flex-wrap:wrap; font-size:11px; opacity:0.72;">
2888
- <span>${inputText}</span>
2889
- <span>${outputText}</span>
3131
+ <div class="wf-node-search-description">${item.description || "No description available."}</div>
3132
+ <div class="wf-node-chip-row">
3133
+ <div class="wf-node-chip-group">
3134
+ <span class="wf-node-chip-label">Inputs</span>
3135
+ <div class="wf-node-chip-list">
3136
+ ${renderChips(io.inputs, "None")}
3137
+ </div>
3138
+ </div>
3139
+ <div class="wf-node-chip-group">
3140
+ <span class="wf-node-chip-label">Outputs</span>
3141
+ <div class="wf-node-chip-list">
3142
+ ${renderChips(io.outputs, "Default")}
3143
+ </div>
3144
+ </div>
2890
3145
  </div>
2891
3146
  </button>
2892
3147
  `;
2893
3148
  })}
2894
3149
  ${results.length === 0 && html`
2895
- <div style="text-align: center; padding: 20px; opacity: 0.6;">No matching nodes</div>
3150
+ <div class="wf-node-search-empty">No matching nodes</div>
2896
3151
  `}
2897
3152
  </div>
2898
3153
  </div>
@@ -2908,9 +3163,9 @@ function KeyboardShortcutOverlay({ open, onClose, canUndo, canRedo }) {
2908
3163
  { keys: "?", description: "Show this shortcut reference" },
2909
3164
  { keys: "Ctrl/Cmd + Z", description: canUndo ? "Undo last graph change" : "Undo unavailable" },
2910
3165
  { keys: "Ctrl/Cmd + Shift + Z", description: canRedo ? "Redo last undone change" : "Redo unavailable" },
2911
- { keys: "Ctrl/Cmd + Y", description: canRedo ? "Alternate redo shortcut" : "Alternate redo shortcut" },
2912
- { keys: "Delete / Backspace", description: "Delete selected node or edge" },
3166
+ { keys: "Ctrl/Cmd + Y", description: canRedo ? "Alternate redo shortcut" : "Alternate redo unavailable" },
2913
3167
  { keys: "Ctrl/Cmd + A", description: "Select all nodes" },
3168
+ { keys: "Delete / Backspace", description: "Delete selected node or edge" },
2914
3169
  { keys: "Space + drag", description: "Pan the canvas" },
2915
3170
  { keys: "Ctrl/Cmd + drag", description: "Alternate mouse panning" },
2916
3171
  { keys: "Shift + click", description: "Add or remove a node from the selection" },
@@ -2919,11 +3174,11 @@ function KeyboardShortcutOverlay({ open, onClose, canUndo, canRedo }) {
2919
3174
  <${Dialog} open=${open} onClose=${onClose} maxWidth="sm" fullWidth>
2920
3175
  <${DialogTitle}>Canvas Shortcuts<//>
2921
3176
  <${DialogContent} dividers>
2922
- <div style="display:flex; flex-direction:column; gap:10px;">
3177
+ <div class="wf-shortcuts-grid">
2923
3178
  ${shortcuts.map((shortcut) => html`
2924
- <div key=${shortcut.keys} style="display:flex; align-items:flex-start; justify-content:space-between; gap:16px;">
2925
- <code style="font-size:12px; padding:3px 8px; border-radius:8px; background:rgba(148,163,184,0.14);">${shortcut.keys}</code>
2926
- <span style="font-size:13px; opacity:0.82; text-align:right;">${shortcut.description}</span>
3179
+ <div key=${shortcut.keys} class="wf-shortcut-row">
3180
+ <code class="wf-shortcut-key">${shortcut.keys}</code>
3181
+ <span class="wf-shortcut-desc">${shortcut.description}</span>
2927
3182
  </div>
2928
3183
  `)}
2929
3184
  </div>
@@ -3140,13 +3395,15 @@ function WorkflowAgentLibraryPicker({ config, onUpdate }) {
3140
3395
  * Node Config Editor (right side panel)
3141
3396
  * ═══════════════════════════════════════════════════════════════ */
3142
3397
 
3143
- function NodeConfigEditor({ node, nodeTypes: types, onUpdate, onUpdateLabel, onClose, onDelete }) {
3398
+ function NodeConfigEditor({ node, nodeTypes: types, inlineFieldKeys = [], onUpdate, onUpdateLabel, onClose, onDelete }) {
3144
3399
  if (!node) return null;
3145
3400
 
3146
3401
  const meta = getNodeMeta(node.type);
3147
3402
  const typeInfo = (types || []).find(nt => nt.type === node.type);
3148
3403
  const schema = typeInfo?.schema?.properties || {};
3149
3404
  const config = node.config || {};
3405
+ const hiddenInlineKeys = new Set((inlineFieldKeys || []).map((key) => String(key || "").trim()).filter(Boolean));
3406
+ const schemaEntries = Object.entries(schema).filter(([key]) => !hiddenInlineKeys.has(key));
3150
3407
  const [presetExpanded, setPresetExpanded] = useState(true);
3151
3408
 
3152
3409
  const onFieldChange = useCallback((key, value) => {
@@ -3494,7 +3751,7 @@ function NodeConfigEditor({ node, nodeTypes: types, onUpdate, onUpdateLabel, onC
3494
3751
 
3495
3752
  <!-- ═══ Config Fields (schema-driven) ═══ -->
3496
3753
  <div style="display: flex; flex-direction: column; gap: 12px;">
3497
- ${Object.entries(schema).map(([key, fieldSchema]) => {
3754
+ ${schemaEntries.map(([key, fieldSchema]) => {
3498
3755
  const value = config[key] ?? fieldSchema.default ?? "";
3499
3756
  const fieldType = fieldSchema.type || "string";
3500
3757
  const isRequired = typeInfo?.schema?.required?.includes(key);
@@ -3565,10 +3822,10 @@ function NodeConfigEditor({ node, nodeTypes: types, onUpdate, onUpdateLabel, onC
3565
3822
  </div>
3566
3823
 
3567
3824
  <!-- No schema fields hint -->
3568
- ${Object.keys(schema).length === 0 && html`
3825
+ ${schemaEntries.length === 0 && html`
3569
3826
  <div style="padding: 12px; background: var(--color-bg-secondary, #1a1f2e); border-radius: 8px; text-align: center; margin-bottom: 12px;">
3570
- <div style="font-size: 12px; color: #6b7280;">This node has no configurable fields.</div>
3571
- <div style="font-size: 10px; color: #4b5563; margin-top: 4px;">It executes with defaults or inherits from workflow context.</div>
3827
+ <div style="font-size: 12px; color: #6b7280;">Advanced settings only.</div>
3828
+ <div style="font-size: 10px; color: #4b5563; margin-top: 4px;">Primary fields are editable inline on the node body.</div>
3572
3829
  </div>
3573
3830
  `}
3574
3831