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.
- package/.env.example +8 -0
- package/README.md +20 -0
- package/agent/agent-event-bus.mjs +248 -6
- package/agent/agent-pool.mjs +125 -28
- package/agent/agent-work-analyzer.mjs +8 -16
- package/agent/retry-queue.mjs +164 -0
- package/bosun.config.example.json +25 -0
- package/bosun.schema.json +825 -183
- package/cli.mjs +59 -5
- package/config/config.mjs +130 -3
- package/infra/monitor.mjs +693 -67
- package/infra/runtime-accumulator.mjs +376 -84
- package/infra/session-tracker.mjs +82 -25
- package/lib/codebase-audit.mjs +133 -18
- package/package.json +23 -4
- package/server/setup-web-server.mjs +25 -0
- package/server/ui-server.mjs +248 -29
- package/setup.mjs +27 -24
- package/shell/codex-shell.mjs +34 -3
- package/shell/copilot-shell.mjs +50 -8
- package/task/msg-hub.mjs +193 -0
- package/task/pipeline.mjs +544 -0
- package/task/task-cli.mjs +38 -2
- package/task/task-executor-pipeline.mjs +143 -0
- package/task/task-executor.mjs +36 -27
- package/telegram/get-telegram-chat-id.mjs +57 -47
- package/ui/components/workspace-switcher.js +7 -7
- package/ui/demo-defaults.js +15694 -10573
- package/ui/modules/settings-schema.js +2 -0
- package/ui/modules/state.js +54 -57
- package/ui/modules/voice-client-sdk.js +375 -36
- package/ui/modules/voice-client.js +140 -31
- package/ui/setup.html +68 -2
- package/ui/styles/components.css +57 -0
- package/ui/styles.css +201 -1
- package/ui/tabs/dashboard.js +74 -0
- package/ui/tabs/logs.js +10 -0
- package/ui/tabs/settings.js +178 -99
- package/ui/tabs/tasks.js +31 -1
- package/ui/tabs/telemetry.js +34 -0
- package/ui/tabs/workflow-canvas-utils.mjs +8 -1
- package/ui/tabs/workflows.js +532 -275
- package/voice/voice-agents-sdk.mjs +1 -1
- package/voice/voice-relay.mjs +6 -6
- package/workflow/declarative-workflows.mjs +145 -0
- package/workflow/msg-hub.mjs +237 -0
- package/workflow/pipeline-workflows.mjs +287 -0
- package/workflow/pipeline.mjs +828 -315
- package/workflow/workflow-cli.mjs +128 -0
- package/workflow/workflow-engine.mjs +329 -17
- package/workflow/workflow-nodes/custom-loader.mjs +250 -0
- package/workflow/workflow-nodes.mjs +1955 -223
- package/workflow/workflow-templates.mjs +26 -8
- package/workflow-templates/agents.mjs +0 -1
- package/workflow-templates/bosun-native.mjs +212 -2
- package/workflow-templates/continuation-loop.mjs +339 -0
- package/workflow-templates/github.mjs +516 -40
- package/workflow-templates/planning.mjs +446 -17
- package/workflow-templates/reliability.mjs +65 -12
- package/workflow-templates/task-batch.mjs +24 -8
- package/workflow-templates/task-lifecycle.mjs +83 -6
- package/workspace/context-cache.mjs +66 -18
- package/workspace/workspace-manager.mjs +2 -1
- package/workflow-templates/issue-continuation.mjs +0 -243
package/ui/tabs/workflows.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
}
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
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
|
|
1256
|
-
|
|
1257
|
-
|
|
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
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
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
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
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
|
|
1305
|
-
const
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
if (
|
|
1319
|
-
|
|
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 (
|
|
1332
|
-
|
|
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
|
-
|
|
1337
|
-
|
|
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
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
const
|
|
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
|
-
|
|
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 =
|
|
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
|
|
2168
|
-
|
|
2169
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
2186
|
-
const
|
|
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(
|
|
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
|
|
2309
|
-
const n = nodes.find(
|
|
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
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
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)"
|
|
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
|
|
2467
|
-
const
|
|
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
|
|
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" :
|
|
2480
|
-
stroke-width=${isSelected ? 3 :
|
|
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=${
|
|
2541
|
-
y1=${
|
|
2697
|
+
x1=${start.x}
|
|
2698
|
+
y1=${start.y}
|
|
2542
2699
|
x2=${mousePos.x}
|
|
2543
2700
|
y2=${mousePos.y}
|
|
2544
|
-
stroke
|
|
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="
|
|
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
|
-
${
|
|
2635
|
-
<
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
<
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
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
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
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=${
|
|
2763
|
-
nodeTypes=${
|
|
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
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
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
|
|
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
|
|
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=${`
|
|
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
|
|
2882
|
-
<span
|
|
2883
|
-
<span
|
|
2884
|
-
|
|
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
|
|
2887
|
-
<div
|
|
2888
|
-
<
|
|
2889
|
-
|
|
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
|
|
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
|
|
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
|
|
3177
|
+
<div class="wf-shortcuts-grid">
|
|
2923
3178
|
${shortcuts.map((shortcut) => html`
|
|
2924
|
-
<div key=${shortcut.keys}
|
|
2925
|
-
<code
|
|
2926
|
-
<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
|
-
${
|
|
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
|
-
${
|
|
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;">
|
|
3571
|
-
<div style="font-size: 10px; color: #4b5563; margin-top: 4px;">
|
|
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
|
|