From f5e3d9eadc2e95cc59f19738921dda53cea69ef6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 27 Jun 2026 05:38:54 +0000 Subject: [PATCH 1/7] feat: upgrade visual workflow editor --- styles.css | 143 ++++++- ui/canvas.ts | 5 +- ui/connections.ts | 8 +- ui/expressionTemplates.ts | 58 +++ ui/node.ts | 2 +- ui/palette.ts | 38 +- ui/panel.ts | 758 +++++++++++++++++++++++++++++++++----- ui/serializer.ts | 2 + ui/state.ts | 8 + ui/types.ts | 1 + workflow/expression.ts | 31 +- workflow/runner.ts | 94 ++++- 12 files changed, 981 insertions(+), 167 deletions(-) create mode 100644 ui/expressionTemplates.ts diff --git a/styles.css b/styles.css index 2f48efb..5a9a593 100644 --- a/styles.css +++ b/styles.css @@ -116,17 +116,23 @@ button, select, input { /* Mobile: sidebar becomes bottom sheet */ @media (max-width: 768px) { .jf-layout { - flex-direction: column; + min-height: 0; } .jf-layout__sidebar { - width: 100%; - max-height: 40vh; - border-left: none; + position: absolute; + left: 0.75rem; + right: 0.75rem; + bottom: 0.75rem; + width: auto; + max-height: min(68vh, 560px); + border: 1px solid var(--border); border-top: 1px solid var(--border); - flex-direction: row; - flex-wrap: wrap; + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + flex-direction: column; overflow-y: auto; + z-index: 20; } } @@ -229,6 +235,15 @@ button, select, input { } @media (max-width: 480px) { + .jf-toolbar { + align-items: flex-start; + } + .jf-toolbar__actions { + width: 100%; + overflow-x: auto; + padding-bottom: 0.15rem; + flex-wrap: nowrap; + } .jf-toolbar__btn-label { display: none; } @@ -302,6 +317,12 @@ button, select, input { overflow: hidden; } +@media (max-width: 768px) { + .jf-node { + width: min(180px, calc(100vw - 2rem)); + } +} + .jf-node:active { cursor: grabbing; } @@ -413,12 +434,11 @@ button, select, input { @media (max-width: 768px) { .jf-palette { - border-bottom: none; - border-right: 1px solid var(--border); - min-width: 50%; + border-right: none; + min-width: 100%; } .jf-palette__items { - grid-template-columns: 1fr 1fr; + grid-template-columns: repeat(2, minmax(0, 1fr)); } } @@ -470,7 +490,7 @@ button, select, input { @media (max-width: 768px) { .jf-panel { - min-width: 50%; + min-width: 100%; } } @@ -513,6 +533,14 @@ button, select, input { padding: 0.75rem 1rem; } +.jf-panel__hint { + padding: 0.75rem; + border: 1px dashed var(--border); + border-radius: var(--radius-sm); + color: var(--text-secondary); + font-size: 0.8rem; +} + .jf-panel__state { display: inline-flex; padding: 0.1rem 0.45rem; @@ -538,6 +566,10 @@ button, select, input { margin-top: 0.3rem; } +.jf-field--fill { + flex: 1; +} + .jf-field__label { font-size: 0.75rem; font-weight: 500; @@ -556,6 +588,17 @@ button, select, input { width: 100%; } +.jf-field__input--compact { + min-width: 0; + max-width: 160px; +} + +.jf-field__textarea { + min-height: 86px; + resize: vertical; + white-space: pre-wrap; +} + .jf-field__input:focus { outline: none; border-color: var(--accent); @@ -597,6 +640,74 @@ button, select, input { background: rgba(2, 6, 20, 0.4); } +.jf-fork, +.jf-fork-branch { + padding: 0.75rem; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: rgba(2, 6, 20, 0.35); +} + +.jf-fork-branch { + margin-top: 0.5rem; +} + +.jf-stack { + display: flex; + flex-direction: column; + gap: 0.65rem; +} + +.jf-card__header, +.jf-section__header, +.jf-inline-controls { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.jf-card__header, +.jf-section__header { + justify-content: space-between; + margin-bottom: 0.5rem; +} + +.jf-inline-controls { + flex-wrap: wrap; + justify-content: flex-end; +} + +.jf-reference-list { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} + +.jf-reference-chip { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.35rem 0.6rem; + border-radius: var(--radius-full); + border: 1px solid rgba(59, 130, 246, 0.28); + background: rgba(59, 130, 246, 0.08); + color: #bfdbfe; + cursor: pointer; + font-size: 0.72rem; + transition: background 0.2s, border-color 0.2s, transform 0.2s; +} + +.jf-reference-chip:hover { + border-color: var(--accent); + background: rgba(59, 130, 246, 0.16); + transform: translateY(-1px); +} + +.jf-reference-chip--active { + background: rgba(59, 130, 246, 0.22); + color: white; +} + .jf-expr__type { display: inline-flex; padding: 0.15rem 0.5rem; @@ -628,6 +739,10 @@ button, select, input { transition: all 0.2s; } +.jf-btn--ghost { + background: transparent; +} + .jf-btn:hover { transform: translateY(-1px); } @@ -706,6 +821,12 @@ button, select, input { border-top: 1px solid var(--border); } +@media (max-width: 768px) { + .jf-results { + min-width: 100%; + } +} + .jf-results__count { display: inline-flex; align-items: center; diff --git a/ui/canvas.ts b/ui/canvas.ts index 82f5eae..461a9b0 100644 --- a/ui/canvas.ts +++ b/ui/canvas.ts @@ -102,12 +102,15 @@ function bindPanEvents(container: HTMLDivElement) { }, { passive: false }); } -export function startNodeDrag(nodeId: string, clientX: number, clientY: number) { +export function startNodeDrag(nodeId: string, clientX: number, clientY: number, pointerId?: number) { const node = getState().nodes.find((n) => n.id === nodeId); if (!node) return; dragNodeId = nodeId; dragStart = { x: clientX, y: clientY }; dragNodeStart = { ...node.position }; + if (containerEl && pointerId != null) { + containerEl.setPointerCapture(pointerId); + } setState({ selectedNodeId: nodeId }); } diff --git a/ui/connections.ts b/ui/connections.ts index 4b9b5a2..36ea3e4 100644 --- a/ui/connections.ts +++ b/ui/connections.ts @@ -2,7 +2,8 @@ import { NodeData, NodeExecutionState } from "./types"; -const NODE_WIDTH = 200; +const NODE_WIDTH = () => + typeof window !== "undefined" && window.innerWidth <= 768 ? Math.min(180, window.innerWidth - 32) : 200; const NODE_HEIGHT = 80; export function renderConnections( @@ -47,9 +48,10 @@ function drawConnection( type: "parent" | "true" | "false", executionStates: Record ) { - const fromX = from.position.x + NODE_WIDTH / 2; + const nodeWidth = NODE_WIDTH(); + const fromX = from.position.x + nodeWidth / 2; const fromY = from.position.y + NODE_HEIGHT; - const toX = to.position.x + NODE_WIDTH / 2; + const toX = to.position.x + nodeWidth / 2; const toY = to.position.y; // Bezier curve diff --git a/ui/expressionTemplates.ts b/ui/expressionTemplates.ts new file mode 100644 index 0000000..838cf9f --- /dev/null +++ b/ui/expressionTemplates.ts @@ -0,0 +1,58 @@ +import { ExpressionData } from "./types"; + +export const EXPRESSION_LIBRARY: { label: string; icon: string; expressionType: string }[] = [ + { label: "Math", icon: "🧮", expressionType: "Math" }, + { label: "Console Log", icon: "📝", expressionType: "ConsoleLog" }, + { label: "HTTP Request", icon: "🌐", expressionType: "HTTPRequest" }, + { label: "Wait", icon: "⏱️", expressionType: "Wait" }, +]; + +export function createExpression(type: string, existingExpressions: ExpressionData[] = []): ExpressionData { + const nextIndex = existingExpressions.length + 1; + return { + id: `expr_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, + name: createExpressionName(type, existingExpressions), + type, + parameters: getDefaultParams(type), + }; +} + +export function getDefaultParams(type: string): { id: string; name: string; value: string }[] { + switch (type) { + case "Math": + return [{ id: "p1", name: "expression", value: "2 + 2" }]; + case "ConsoleLog": + return [{ id: "p1", name: "data", value: "Hello from JFlow" }]; + case "HTTPRequest": + return [ + { id: "p1", name: "url", value: "https://httpbin.org/get" }, + { id: "p2", name: "method", value: "GET" }, + ]; + case "Wait": + return [{ id: "p1", name: "seconds", value: "2" }]; + default: + return []; + } +} + +export function getExpressionMeta(type: string) { + return ( + EXPRESSION_LIBRARY.find((entry) => entry.expressionType === type) ?? { + label: type, + icon: "◆", + expressionType: type, + } + ); +} + +function createExpressionName(type: string, existingExpressions: ExpressionData[]): string { + const base = type.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/\s+/g, "_").toLowerCase(); + const taken = new Set(existingExpressions.map((expression) => expression.name)); + let counter = 1; + let candidate = `${base}_${counter}`; + while (taken.has(candidate)) { + counter += 1; + candidate = `${base}_${counter}`; + } + return candidate; +} diff --git a/ui/node.ts b/ui/node.ts index 785de1c..75a83c0 100644 --- a/ui/node.ts +++ b/ui/node.ts @@ -21,7 +21,7 @@ export function createNodeElement(node: NodeData): HTMLElement { // Drag handler el.addEventListener("pointerdown", (e) => { e.stopPropagation(); - startNodeDrag(node.id, e.clientX, e.clientY); + startNodeDrag(node.id, e.clientX, e.clientY, e.pointerId); }); // Tap to select diff --git a/ui/palette.ts b/ui/palette.ts index 199cf8e..2a413e2 100644 --- a/ui/palette.ts +++ b/ui/palette.ts @@ -1,14 +1,8 @@ /** Node palette – drag new blocks onto canvas */ import { addNode, getState } from "./state"; -import { NodeData, ExpressionData } from "./types"; - -const BLOCK_TEMPLATES: { label: string; icon: string; expressionType: string }[] = [ - { label: "Math", icon: "🧮", expressionType: "Math" }, - { label: "Console Log", icon: "📝", expressionType: "ConsoleLog" }, - { label: "HTTP Request", icon: "🌐", expressionType: "HTTPRequest" }, - { label: "Wait", icon: "⏱️", expressionType: "Wait" }, -]; +import { NodeData } from "./types"; +import { EXPRESSION_LIBRARY, createExpression } from "./expressionTemplates"; let paletteEl: HTMLDivElement; @@ -23,7 +17,7 @@ export function initPalette(container: HTMLElement) {
- ${BLOCK_TEMPLATES.map( + ${EXPRESSION_LIBRARY.map( (t, i) => ` Node Properties - +
@@ -61,102 +60,640 @@ function renderPanel(node: NodeData | null) {
- Parent Blocks -
- ${node.parentBlocks.map((p) => `${p}`).join("")} - + Parents +
+ ${parentOptions + .map( + (option) => ` + + ` + ) + .join("")}
- Expressions ${stateLabel} - ${node.expressions - .map( - (expr, i) => ` -
- ${expr.type} - ${expr.parameters - .map( - (p) => ` - - ` - ) - .join("")} +
+ Expressions ${stateLabel} +
+ +
+
+
+ ${node.expressions + .map((expression, expressionIndex) => renderExpression(node, expression, expressionIndex)) + .join("")} +
+
+
+
+ Forks + +
+
+ ${node.forks.length === 0 ? `
Add a fork to create decision branches.
` : ""} + ${node.forks.map((fork, forkIndex) => renderFork(node, fork, forkIndex)).join("")} +
+
+
+ +
+
+
+ `; +} + +function renderExpression(node: NodeData, expression: ExpressionData, expressionIndex: number): string { + const referenceOptions = getReferenceOptions(node, expressionIndex); + + return ` +
+
+ ${escapeHtml(expression.type)} + +
+ + + ${expression.parameters + .map( + (parameter) => ` + + ` + ) + .join("")} + ${ + referenceOptions.length > 0 + ? ` +
+ Reference values +
+ ${referenceOptions + .map( + (reference) => ` + + ` + ) + .join("")} +
+
+ ` + : "" + } +
+ `; +} + +function renderFork(node: NodeData, fork: ForkData, forkIndex: number): string { + return ` +
+
+ + +
+
+ ${fork.branches.map((branch, branchIndex) => renderForkBranch(node, branch, forkIndex, branchIndex)).join("")} + +
+
+ `; +} + +function renderForkBranch(node: NodeData, branch: ForkBranchData, forkIndex: number, branchIndex: number): string { + const branchTargets = getBranchTargets(node); + const branchReferences = getForkReferenceOptions(node); + + return ` +
+
+ Branch ${branchIndex + 1} + +
+ + ${ + branchReferences.length > 0 + ? ` +
+ Reference values +
+ ${branchReferences + .map( + (reference) => ` + + ` + ) + .join("")} +
+
+ ` + : "" + } +
+ If true +
+ ${branchTargets + .map((target) => + renderTargetChip(target.id, target.label, branch.resultTrueBlocks.includes(target.id), forkIndex, branchIndex, "true") ) .join("")}
-
- +
+
+ If false +
+ ${branchTargets + .map((target) => + renderTargetChip(target.id, target.label, branch.resultFalseBlocks.includes(target.id), forkIndex, branchIndex, "false") + ) + .join("")}
`; +} - // Event bindings - panelEl.querySelector(".jf-panel__close")?.addEventListener("click", () => { - setState({ selectedNodeId: null }); - }); +function renderTargetChip( + id: string, + label: string, + active: boolean, + forkIndex: number, + branchIndex: number, + branchType: "true" | "false" +): string { + return ` + + `; +} - panelEl.querySelector('[data-field="name"]')?.addEventListener("input", (e) => { - updateNode(node.id, { name: (e.target as HTMLInputElement).value }); - }); +function handleClick(event: Event) { + const target = event.target as HTMLElement; + + if (target.closest(".jf-collapsible__toggle")) { + panelEl.classList.toggle("jf-collapsible--collapsed"); + const btn = panelEl.querySelector(".jf-collapsible__toggle"); + if (btn) btn.textContent = panelEl.classList.contains("jf-collapsible--collapsed") ? "▸" : "▾"; + return; + } + + const actionEl = target.closest("[data-action]"); + if (!actionEl) return; + + const action = actionEl.dataset.action; + const node = getCurrentNode(); + if (!node) return; - panelEl.querySelector('[data-field="id"]')?.addEventListener("change", (e) => { - const newId = (e.target as HTMLInputElement).value.trim(); - if (newId && newId !== node.id) { - // Update references in other nodes - const { nodes } = getState(); - nodes.forEach((n) => { - if (n.parentBlocks.includes(node.id)) { - updateNode(n.id, { - parentBlocks: n.parentBlocks.map((p) => (p === node.id ? newId : p)), - }); - } + switch (action) { + case "close": + setState({ selectedNodeId: null }); + return; + case "delete": + removeNode(node.id); + return; + case "add-expression": { + const type = (panelEl.querySelector('[data-field="new-expression-type"]') as HTMLSelectElement | null)?.value ?? "Math"; + forcePanelRefresh(); + updateNode(node.id, { expressions: [...node.expressions, createExpression(type, node.expressions)] }); + return; + } + case "remove-expression": { + if (node.expressions.length === 1) return; + const expressionIndex = Number(actionEl.dataset.expressionIndex); + forcePanelRefresh(); + updateNode(node.id, { expressions: node.expressions.filter((_, index) => index !== expressionIndex) }); + return; + } + case "add-fork": + forcePanelRefresh(); + updateNode(node.id, { + forks: [ + ...node.forks, + { + id: `fork_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, + name: `Fork ${node.forks.length + 1}`, + branches: [createForkBranch()], + }, + ], }); - updateNode(node.id, { id: newId }); + return; + case "remove-fork": { + const forkIndex = Number(actionEl.dataset.forkIndex); + forcePanelRefresh(); + updateNode(node.id, { forks: node.forks.filter((_, index) => index !== forkIndex) }); + return; } - }); - - // Expression parameter editing - panelEl.querySelectorAll("[data-expr]").forEach((input) => { - input.addEventListener("input", (e) => { - const exprIdx = parseInt(input.dataset.expr!, 10); - const paramName = input.dataset.param!; - const newExprs = [...node.expressions]; - newExprs[exprIdx] = { - ...newExprs[exprIdx], - parameters: newExprs[exprIdx].parameters.map((p) => - p.name === paramName ? { ...p, value: (e.target as HTMLInputElement).value } : p + case "add-branch": { + const forkIndex = Number(actionEl.dataset.forkIndex); + forcePanelRefresh(); + const forks = node.forks.map((fork, index) => + index === forkIndex ? { ...fork, branches: [...fork.branches, createForkBranch()] } : fork + ); + updateNode(node.id, { forks }); + return; + } + case "remove-branch": { + const forkIndex = Number(actionEl.dataset.forkIndex); + const branchIndex = Number(actionEl.dataset.branchIndex); + forcePanelRefresh(); + const forks = node.forks.map((fork, index) => + index === forkIndex + ? { ...fork, branches: fork.branches.filter((_, nestedIndex) => nestedIndex !== branchIndex) } + : fork + ); + updateNode(node.id, { forks }); + return; + } + case "toggle-parent": { + const parentId = actionEl.dataset.parentId!; + const nextParents = node.parentBlocks.includes(parentId) + ? node.parentBlocks.filter((parent) => parent !== parentId) + : [...node.parentBlocks, parentId]; + forcePanelRefresh(); + updateNode(node.id, { parentBlocks: dedupeIds(nextParents) }); + return; + } + case "toggle-branch-target": { + const forkIndex = Number(actionEl.dataset.forkIndex); + const branchIndex = Number(actionEl.dataset.branchIndex); + const branchType = actionEl.dataset.branchTargetType as "true" | "false"; + const targetId = actionEl.dataset.targetId!; + forcePanelRefresh(); + updateNode(node.id, { + forks: node.forks.map((fork, index) => { + if (index !== forkIndex) return fork; + return { + ...fork, + branches: fork.branches.map((branch, nestedIndex) => { + if (nestedIndex !== branchIndex) return branch; + const currentTargets = branchType === "true" ? branch.resultTrueBlocks : branch.resultFalseBlocks; + const nextTargets = currentTargets.includes(targetId) + ? currentTargets.filter((id) => id !== targetId) + : [...currentTargets, targetId]; + return branchType === "true" + ? { ...branch, resultTrueBlocks: dedupeIds(nextTargets) } + : { ...branch, resultFalseBlocks: dedupeIds(nextTargets) }; + }), + }; + }), + }); + return; + } + case "insert-reference": { + const expressionIndex = Number(actionEl.dataset.expressionIndex); + const reference = actionEl.dataset.reference ?? ""; + forcePanelRefresh(); + const expressions = node.expressions.map((expression, index) => + index !== expressionIndex + ? expression + : { + ...expression, + parameters: expression.parameters.map((parameter, parameterIndex) => + parameterIndex === 0 ? { ...parameter, value: appendReference(parameter.value, reference) } : parameter + ), + } + ); + updateNode(node.id, { expressions }); + return; + } + case "insert-branch-reference": { + const forkIndex = Number(actionEl.dataset.forkIndex); + const branchIndex = Number(actionEl.dataset.branchIndex); + const reference = actionEl.dataset.reference ?? ""; + forcePanelRefresh(); + updateNode(node.id, { + forks: node.forks.map((fork, index) => + index !== forkIndex + ? fork + : { + ...fork, + branches: fork.branches.map((branch, nestedIndex) => + nestedIndex !== branchIndex + ? branch + : { + ...branch, + statement: insertBranchReference(branch.statement, reference), + } + ), + } ), - }; - updateNode(node.id, { expressions: newExprs }); + }); + return; + } + } +} + +function handleInput(event: Event) { + const target = event.target as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement; + const node = getCurrentNode(); + if (!node) return; + + if (target.dataset.field === "name") { + updateNode(node.id, { name: target.value }); + return; + } + + if (target.dataset.expressionField === "name") { + const expressionIndex = Number(target.dataset.expressionIndex); + updateNode(node.id, { + expressions: node.expressions.map((expression, index) => + index === expressionIndex ? { ...expression, name: target.value.trim() || expression.name } : expression + ), }); - }); + return; + } - // Add parent - panelEl.querySelector('[data-action="add-parent"]')?.addEventListener("click", () => { - const other = getState().nodes.filter((n) => n.id !== node.id && !node.parentBlocks.includes(n.id)); - if (other.length === 0) { - // Set as root - updateNode(node.id, { parentBlocks: [...node.parentBlocks, "workflow"] }); - } else { - // Pick first available (in a real app we'd show a picker) - const picked = prompt("Enter parent block ID:", other[0]?.id ?? "workflow"); - if (picked) { - updateNode(node.id, { parentBlocks: [...node.parentBlocks, picked] }); - } - } - currentNodeId = null; // force re-render - }); + if (target.dataset.paramName) { + const expressionIndex = Number(target.dataset.expressionIndex); + const paramName = target.dataset.paramName; + updateNode(node.id, { + expressions: node.expressions.map((expression, index) => + index !== expressionIndex + ? expression + : { + ...expression, + parameters: expression.parameters.map((parameter) => + parameter.name === paramName ? { ...parameter, value: target.value } : parameter + ), + } + ), + }); + return; + } + + if (target.dataset.forkField === "name") { + const forkIndex = Number(target.dataset.forkIndex); + updateNode(node.id, { + forks: node.forks.map((fork, index) => (index === forkIndex ? { ...fork, name: target.value } : fork)), + }); + return; + } + + if (target.dataset.branchField === "statement") { + const forkIndex = Number(target.dataset.forkIndex); + const branchIndex = Number(target.dataset.branchIndex); + updateNode(node.id, { + forks: node.forks.map((fork, index) => + index !== forkIndex + ? fork + : { + ...fork, + branches: fork.branches.map((branch, nestedIndex) => + nestedIndex === branchIndex ? { ...branch, statement: target.value } : branch + ), + } + ), + }); + } +} - // Delete - panelEl.querySelector('[data-action="delete"]')?.addEventListener("click", () => { - removeNode(node.id); +function handleChange(event: Event) { + const target = event.target as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement; + const node = getCurrentNode(); + if (!node) return; + + if (target.dataset.field === "id") { + renameNode(node, target.value.trim()); + return; + } + + if (target.dataset.expressionField === "type") { + const expressionIndex = Number(target.dataset.expressionIndex); + forcePanelRefresh(); + updateNode(node.id, { + expressions: node.expressions.map((expression, index) => + index === expressionIndex + ? { + ...expression, + type: target.value, + parameters: createExpression(target.value).parameters, + } + : expression + ), + }); + return; + } + + if (target.dataset.branchField === "statement") { + const forkIndex = Number(target.dataset.forkIndex); + const branchIndex = Number(target.dataset.branchIndex); + forcePanelRefresh(); + updateNode(node.id, { + forks: node.forks.map((fork, index) => + index !== forkIndex + ? fork + : { + ...fork, + branches: fork.branches.map((branch, nestedIndex) => + nestedIndex === branchIndex ? { ...branch, statement: parseStatementInput(target.value) } : branch + ), + } + ), + }); + } +} + +function renameNode(node: NodeData, newId: string) { + if (!newId || newId === node.id) return; + + const { nodes } = getState(); + if (nodes.some((candidate) => candidate.id === newId)) return; + + nodes.forEach((candidate) => { + if (candidate.id === node.id) return; + + const parentBlocks = candidate.parentBlocks.map((parentId) => (parentId === node.id ? newId : parentId)); + const forks = candidate.forks.map((fork) => ({ + ...fork, + branches: fork.branches.map((branch) => ({ + ...branch, + resultTrueBlocks: branch.resultTrueBlocks.map((targetId) => (targetId === node.id ? newId : targetId)), + resultFalseBlocks: branch.resultFalseBlocks.map((targetId) => (targetId === node.id ? newId : targetId)), + })), + })); + + updateNode(candidate.id, { parentBlocks, forks }); }); + + updateNode(node.id, { id: newId }); + currentNodeId = newId; + setState({ selectedNodeId: newId }); +} + +function getCurrentNode(): NodeData | null { + const { selectedNodeId, nodes } = getState(); + return nodes.find((node) => node.id === selectedNodeId) ?? null; +} + +function forcePanelRefresh() { + currentNodeId = null; +} + +function getParentOptions(node: NodeData) { + const otherNodes = getState().nodes.filter((candidate) => candidate.id !== node.id); + return [ + { id: "workflow", label: "Workflow root", active: node.parentBlocks.includes("workflow") }, + ...otherNodes.map((candidate) => ({ + id: candidate.id, + label: candidate.name, + active: node.parentBlocks.includes(candidate.id), + })), + ]; +} + +function getBranchTargets(node: NodeData) { + return getState().nodes + .filter((candidate) => candidate.id !== node.id) + .map((candidate) => ({ id: candidate.id, label: candidate.name })); +} + +function getReferenceOptions(node: NodeData, expressionIndex: number) { + const currentBlockReferences = node.expressions.slice(0, expressionIndex).map((expression) => ({ + label: `current.${expression.name}`, + template: `{{current.${expression.name}}}`, + forkToken: `$${expression.name}`, + })); + + const upstreamReferences = collectUpstreamNodes(node.id).flatMap((upstreamNode) => + upstreamNode.expressions.map((expression) => ({ + label: `${upstreamNode.id}.${expression.name}`, + template: `{{${upstreamNode.id}.${expression.name}}}`, + forkToken: `$${upstreamNode.id}.${expression.name}`, + })) + ); + + return [...currentBlockReferences, ...upstreamReferences]; +} + +function getForkReferenceOptions(node: NodeData) { + return [ + ...node.expressions.map((expression) => ({ + label: `current.${expression.name}`, + template: `{{current.${expression.name}}}`, + forkToken: `$${expression.name}`, + })), + ...collectUpstreamNodes(node.id).flatMap((upstreamNode) => + upstreamNode.expressions.map((expression) => ({ + label: `${upstreamNode.id}.${expression.name}`, + template: `{{${upstreamNode.id}.${expression.name}}}`, + forkToken: `$${upstreamNode.id}.${expression.name}`, + })) + ), + ]; +} + +function collectUpstreamNodes(nodeId: string): NodeData[] { + const { nodes } = getState(); + const visited = new Set(); + const queue = [...(nodes.find((node) => node.id === nodeId)?.parentBlocks ?? [])]; + const upstreamNodes: NodeData[] = []; + + while (queue.length > 0) { + const parentId = queue.shift(); + if (!parentId || parentId === "workflow" || visited.has(parentId)) continue; + visited.add(parentId); + + const parentNode = nodes.find((node) => node.id === parentId); + if (!parentNode) continue; + + upstreamNodes.push(parentNode); + queue.push(...parentNode.parentBlocks); + } + + return upstreamNodes; +} + +function createForkBranch(): ForkBranchData { + return { + statement: ["==", "$result", ""], + resultTrueBlocks: [], + resultFalseBlocks: [], + }; } function renderPlaceholder(): string { @@ -168,11 +705,54 @@ function renderPlaceholder(): string { Properties
-
Select a node to edit its properties
+
Select a node to edit expressions, forks, and references.
`; } +function parseStatementInput(value: string): any { + const trimmed = value.trim(); + if (!trimmed) return ""; + + try { + return JSON.parse(trimmed); + } catch { + return trimmed; + } +} + +function stringifyStatement(statement: any): string { + if (typeof statement === "string") return statement; + try { + return JSON.stringify(statement); + } catch { + return String(statement ?? ""); + } +} + +function appendReference(value: string, reference: string): string { + return value ? `${value} ${reference}`.trim() : reference; +} + +function insertBranchReference(statement: any, reference: string): string { + const current = stringifyStatement(statement); + if (!current.trim()) { + return JSON.stringify(["==", reference, ""]); + } + if (current.includes("$result")) { + return current.replace("$result", reference); + } + return current; +} + +function dedupeIds(ids: string[]): string[] { + return Array.from(new Set(ids.filter(Boolean))); +} + +function escapeHtml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">"); +} + function escapeAttr(s: string): string { - return s.replace(/"/g, """).replace(/ ({ id: expr.id, + name: expr.name, type: expr.type, parameters: expr.parameters.map((p) => ({ id: p.id, @@ -47,6 +48,7 @@ export function deserialize(yamlStr: string) { const nodes: NodeData[] = (def.blocks ?? []).map((block: any, index: number) => { const expressions: ExpressionData[] = (block.expressions ?? []).map((expr: any) => ({ id: expr.id || `expr_${Math.random().toString(36).slice(2, 6)}`, + name: expr.name || expr.id || expr.type?.toLowerCase?.() || "result", type: expr.type, parameters: (expr.parameters ?? []).map((p: any) => ({ id: p.id || "", diff --git a/ui/state.ts b/ui/state.ts index ce344fc..36badf6 100644 --- a/ui/state.ts +++ b/ui/state.ts @@ -58,6 +58,14 @@ export function removeNode(id: string) { .map((n) => ({ ...n, parentBlocks: n.parentBlocks.filter((p) => p !== id), + forks: n.forks.map((fork) => ({ + ...fork, + branches: fork.branches.map((branch) => ({ + ...branch, + resultTrueBlocks: branch.resultTrueBlocks.filter((targetId) => targetId !== id), + resultFalseBlocks: branch.resultFalseBlocks.filter((targetId) => targetId !== id), + })), + })), })), selectedNodeId: state.selectedNodeId === id ? null : state.selectedNodeId, }; diff --git a/ui/types.ts b/ui/types.ts index 47ba332..67be39f 100644 --- a/ui/types.ts +++ b/ui/types.ts @@ -17,6 +17,7 @@ export interface NodeData { export interface ExpressionData { id: string; + name: string; type: string; parameters: { id: string; name: string; value: string }[]; } diff --git a/workflow/expression.ts b/workflow/expression.ts index 3441fbf..ffe1a25 100644 --- a/workflow/expression.ts +++ b/workflow/expression.ts @@ -81,7 +81,7 @@ export class WorkflowExpression { constructor(id: string, name: string, parameters: WorkflowExpressionParameter[], withResult: WorkflowExpression | null = null) { this.id = id; - this.name = name; + this.name = name || id || 'result'; this.parameters = {}; this.withResult = withResult; for (let param of parameters) { @@ -121,16 +121,16 @@ export class WorkflowExpression { // use WorkflowExpressionType to instantiate the correct type if (obj.type === WorkflowExpressionType.Math) { - return new WorkflowExpressionMath(obj.id, obj.name, parameters, withResult); + return new WorkflowExpressionMath(obj.id, obj.name || obj.id || 'result', parameters, withResult); } else if (obj.type === WorkflowExpressionType.ConsoleLog) { - return new WorkflowExpressionConsoleLog(obj.id, obj.name, parameters, withResult); + return new WorkflowExpressionConsoleLog(obj.id, obj.name || obj.id || 'result', parameters, withResult); } else if (obj.type === WorkflowExpressionType.Wait) { - return new WorkflowExpressionWait(obj.id, obj.name, parameters, withResult); + return new WorkflowExpressionWait(obj.id, obj.name || obj.id || 'result', parameters, withResult); } else if (obj.type === WorkflowExpressionType.HTTPRequest) { - return new WorkflowExpressionHTTPRequest(obj.id, obj.name, parameters, withResult); + return new WorkflowExpressionHTTPRequest(obj.id, obj.name || obj.id || 'result', parameters, withResult); } throw new Error('Unknown workflow expression type: ' + obj.type); @@ -151,14 +151,12 @@ export class WorkflowExpressionMath extends WorkflowExpression { */ public async compute(context: any = {}): Promise { // FIXME: this.parameters.expression is untyped - let result = evaluate(this.parameters.expression); - if (context) { - result = this.contextualize(context, result); - } + const expression = this.contextualize(context, this.parameters.expression); + let result = evaluate(expression); if (this.withResult) { result = await this.withResult.compute(result); } - return new WorkflowExpressionResult("result", "result", WorkflowExpressionResultType.String, result.toString()); + return new WorkflowExpressionResult(this.id || this.name, this.name, WorkflowExpressionResultType.String, result.toString()); } } @@ -182,7 +180,7 @@ export class WorkflowExpressionConsoleLog extends WorkflowExpression { result = await this.withResult.compute(result); } console.log(result); - return new WorkflowExpressionResult("", "", WorkflowExpressionResultType.String, result.toString()); + return new WorkflowExpressionResult(this.id || this.name, this.name, WorkflowExpressionResultType.String, result.toString()); } } @@ -206,7 +204,7 @@ export class WorkflowExpressionWait extends WorkflowExpression { result = await this.withResult.compute(result); } await new Promise(resolve => setTimeout(resolve, result * 1000)); - return new WorkflowExpressionResult("", "", WorkflowExpressionResultType.String, result.toString()); + return new WorkflowExpressionResult(this.id || this.name, this.name, WorkflowExpressionResultType.String, result.toString()); } } @@ -221,10 +219,13 @@ export class WorkflowExpressionHTTPRequest extends WorkflowExpression { * @returns The result of an HTTP request wrapped in a WorkflowExpressionResult. */ public async compute(context: any = {}): Promise { - const { url, method, headers, data } = this.parameters; + const url = this.contextualize(context, this.parameters.url); + const method = this.contextualize(context, this.parameters.method); + const headers = this.parameters.headers ? this.contextualize(context, this.parameters.headers) : undefined; + const data = this.parameters.data ? this.contextualize(context, this.parameters.data) : undefined; const response = await fetch(url, { method: method, - headers: headers, + headers: headers as any, body: data }); const json = await response.json(); @@ -234,7 +235,7 @@ export class WorkflowExpressionHTTPRequest extends WorkflowExpression { } else { result = JSON.stringify(json); } - return new WorkflowExpressionResult("", "", WorkflowExpressionResultType.String, result.toString()); + return new WorkflowExpressionResult(this.id || this.name, this.name, WorkflowExpressionResultType.String, result.toString()); } } diff --git a/workflow/runner.ts b/workflow/runner.ts index bd7786b..a42b463 100644 --- a/workflow/runner.ts +++ b/workflow/runner.ts @@ -34,7 +34,8 @@ export class WorkflowRunner { rootBlocks.push(block); } } - const results = await Promise.all(rootBlocks.map(block => this.executeBlockTree(block, 0))); + const baseContext = this.createContext(); + const results = await Promise.all(rootBlocks.map(block => this.executeBlockTree(block, 0, baseContext))); return new WorkflowResult(this.workflow.id, this.workflow.name, results.flat(), new Date(), new Date(), new Date()); } @@ -45,17 +46,18 @@ export class WorkflowRunner { } } - private async executeBlockTree(block: WorkflowBlock, depth: number): Promise { + private async executeBlockTree(block: WorkflowBlock, depth: number, context: ExecutionContext): Promise { if (depth > WorkflowRunner.MAX_DEPTH) { throw new Error(`Workflow exceeded maximum depth of ${WorkflowRunner.MAX_DEPTH} blocks`); } try { - const result = await this.executeBlock(block); + const result = await this.executeBlock(block, context); this.setBlockState(block, BlockState.Finished, result); - const nextBlocks = [...this.findChildBlocks(block), ...this.evaluateFork(block, result)]; - const nestedResults = await Promise.all(nextBlocks.map(nextBlock => this.executeBlockTree(nextBlock, depth + 1))); + const nextContext = this.extendContext(context, block, result); + const nextBlocks = this.dedupeBlocks([...this.findChildBlocks(block), ...this.evaluateFork(block, result, nextContext)]); + const nestedResults = await Promise.all(nextBlocks.map(nextBlock => this.executeBlockTree(nextBlock, depth + 1, nextContext))); return [result, ...nestedResults.flat()]; } catch (err) { @@ -64,9 +66,9 @@ export class WorkflowRunner { } } - private async executeBlock(block: WorkflowBlock): Promise { + private async executeBlock(block: WorkflowBlock, context: ExecutionContext): Promise { this.setBlockState(block, BlockState.Running); - const expressionsResults = await this.evaluateExpressions(block.expressions); + const expressionsResults = await this.evaluateExpressions(block.expressions, context); // Flatten the expressionsResults into a single object with all of the results.name and results.value properties const resultsObject: { [key: string]: any } = {}; for (const result of expressionsResults) { @@ -75,17 +77,31 @@ export class WorkflowRunner { return new WorkflowBlockResult(block.id, block.name, "String", resultsObject); } - private async evaluateExpressions(expressions: WorkflowExpression[]): Promise { - // Compute all expressions by calling expression.compute() at the same time and return the results as an array of Promises - const results = await Promise.all(expressions.map(e => e.compute())); + private async evaluateExpressions(expressions: WorkflowExpression[], context: ExecutionContext): Promise { + const results: WorkflowExpressionResult[] = []; + const currentResults: { [key: string]: any } = {}; + for (const expression of expressions) { + const scopedContext = this.toExpressionContext(context, currentResults); + const result = await expression.compute(scopedContext); + results.push(result); + currentResults[result.name] = result.value; + currentResults.result = result.value; + currentResults.last = result.value; + } return results; - } - private evaluateFork(block: WorkflowBlock, results: WorkflowBlockResult): WorkflowBlock[] { - // First, flatten the results into a single object with all of the results.name and results.value properties + private evaluateFork(block: WorkflowBlock, results: WorkflowBlockResult, context: ExecutionContext): WorkflowBlock[] { const blocksToExecute: WorkflowBlock[] = []; - const resultsObject = results.value; + const resultsObject = { + ...context.workflow, + ...context.blocks, + ...results.value, + current: results.value, + workflow: context.workflow, + blocks: context.blocks, + result: results.value.result ?? Object.values(results.value)[0], + }; // for each fork, evaluate them and return the results for (const fork of block.forks) { // Evaluate the fork @@ -104,4 +120,54 @@ export class WorkflowRunner { // Filter through workflow blocks and find all blocks that are children of this block return this.workflow.blocks.filter(b => b.parentBlocks.includes(block.id)); } + + private dedupeBlocks(blocks: WorkflowBlock[]): WorkflowBlock[] { + const seen = new Set(); + return blocks.filter(block => { + if (seen.has(block.id)) return false; + seen.add(block.id); + return true; + }); + } + + private createContext(): ExecutionContext { + return { + workflow: {}, + blocks: {}, + }; + } + + private extendContext(context: ExecutionContext, block: WorkflowBlock, result: WorkflowBlockResult): ExecutionContext { + const blockResults = result.value; + return { + workflow: { + ...context.workflow, + [block.id]: blockResults, + }, + blocks: { + ...context.blocks, + [block.id]: blockResults, + }, + lastResult: blockResults.result ?? Object.values(blockResults)[0], + }; + } + + private toExpressionContext(context: ExecutionContext, currentResults: { [key: string]: any }): any { + return { + ...context.workflow, + ...context.blocks, + ...currentResults, + current: currentResults, + workflow: context.workflow, + blocks: context.blocks, + last: currentResults.last, + result: currentResults.result ?? context.lastResult, + }; + } +} + +interface ExecutionContext { + workflow: Record; + blocks: Record; + lastResult?: any; } \ No newline at end of file From 8be255dc8f35512afe217879b76c48ca63174418 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 27 Jun 2026 05:44:49 +0000 Subject: [PATCH 2/7] fix: polish editor workflow builder --- styles.css | 8 +++++++- ui/canvas.ts | 6 ++++++ ui/connections.ts | 11 +++++----- ui/expressionTemplates.ts | 19 +++++++++++++++--- ui/ids.ts | 3 +++ ui/palette.ts | 3 ++- ui/panel.ts | 42 +++++++++++++++++++++++++++++++-------- workflow/expression.ts | 24 +++++++++++++--------- workflow/parser.ts | 7 +++++-- workflow/runner.ts | 41 +++++++++++++++++++------------------- 10 files changed, 114 insertions(+), 50 deletions(-) create mode 100644 ui/ids.ts diff --git a/styles.css b/styles.css index 5a9a593..616c58a 100644 --- a/styles.css +++ b/styles.css @@ -125,7 +125,7 @@ button, select, input { right: 0.75rem; bottom: 0.75rem; width: auto; - max-height: min(68vh, 560px); + max-height: min(68dvh, 560px); border: 1px solid var(--border); border-top: 1px solid var(--border); border-radius: var(--radius-lg); @@ -747,6 +747,12 @@ button, select, input { transform: translateY(-1px); } +.jf-btn:disabled { + opacity: 0.45; + cursor: not-allowed; + transform: none; +} + .jf-btn--danger { border-color: rgba(239, 68, 68, 0.3); color: var(--danger); diff --git a/ui/canvas.ts b/ui/canvas.ts index 461a9b0..097a932 100644 --- a/ui/canvas.ts +++ b/ui/canvas.ts @@ -19,6 +19,7 @@ let panOffsetStart: Position = { x: 0, y: 0 }; let dragNodeId: string | null = null; let dragStart: Position = { x: 0, y: 0 }; let dragNodeStart: Position = { x: 0, y: 0 }; +let dragPointerId: number | null = null; export function initCanvas(container: HTMLDivElement) { containerEl = container; @@ -90,6 +91,10 @@ function bindPanEvents(container: HTMLDivElement) { } if (dragNodeId) { dragNodeId = null; + if (dragPointerId != null && container.hasPointerCapture(dragPointerId)) { + container.releasePointerCapture(dragPointerId); + } + dragPointerId = null; } }); @@ -109,6 +114,7 @@ export function startNodeDrag(nodeId: string, clientX: number, clientY: number, dragStart = { x: clientX, y: clientY }; dragNodeStart = { ...node.position }; if (containerEl && pointerId != null) { + dragPointerId = pointerId; containerEl.setPointerCapture(pointerId); } setState({ selectedNodeId: nodeId }); diff --git a/ui/connections.ts b/ui/connections.ts index 36ea3e4..37fbf15 100644 --- a/ui/connections.ts +++ b/ui/connections.ts @@ -15,6 +15,7 @@ export function renderConnections( svg.innerHTML = ""; const nodeMap = new Map(nodes.map((n) => [n.id, n])); + const nodeWidth = NODE_WIDTH(); for (const node of nodes) { // Parent → child connections @@ -22,7 +23,7 @@ export function renderConnections( if (parentId === "workflow") continue; const parent = nodeMap.get(parentId); if (!parent) continue; - drawConnection(svg, parent, node, "parent", executionStates); + drawConnection(svg, parent, node, "parent", executionStates, nodeWidth); } // Fork connections @@ -30,11 +31,11 @@ export function renderConnections( for (const branch of fork.branches) { for (const targetId of branch.resultTrueBlocks) { const target = nodeMap.get(targetId); - if (target) drawConnection(svg, node, target, "true", executionStates); + if (target) drawConnection(svg, node, target, "true", executionStates, nodeWidth); } for (const targetId of branch.resultFalseBlocks) { const target = nodeMap.get(targetId); - if (target) drawConnection(svg, node, target, "false", executionStates); + if (target) drawConnection(svg, node, target, "false", executionStates, nodeWidth); } } } @@ -46,9 +47,9 @@ function drawConnection( from: NodeData, to: NodeData, type: "parent" | "true" | "false", - executionStates: Record + executionStates: Record, + nodeWidth: number ) { - const nodeWidth = NODE_WIDTH(); const fromX = from.position.x + nodeWidth / 2; const fromY = from.position.y + NODE_HEIGHT; const toX = to.position.x + nodeWidth / 2; diff --git a/ui/expressionTemplates.ts b/ui/expressionTemplates.ts index 838cf9f..697911f 100644 --- a/ui/expressionTemplates.ts +++ b/ui/expressionTemplates.ts @@ -1,4 +1,5 @@ import { ExpressionData } from "./types"; +import { generateId } from "./ids"; export const EXPRESSION_LIBRARY: { label: string; icon: string; expressionType: string }[] = [ { label: "Math", icon: "🧮", expressionType: "Math" }, @@ -8,15 +9,18 @@ export const EXPRESSION_LIBRARY: { label: string; icon: string; expressionType: ]; export function createExpression(type: string, existingExpressions: ExpressionData[] = []): ExpressionData { - const nextIndex = existingExpressions.length + 1; return { - id: `expr_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, + id: generateId("expr"), name: createExpressionName(type, existingExpressions), type, parameters: getDefaultParams(type), }; } +export function fallbackExpressionName(type: string, index: number): string { + return `${toExpressionNameBase(type)}_${index + 1}`; +} + export function getDefaultParams(type: string): { id: string; name: string; value: string }[] { switch (type) { case "Math": @@ -46,7 +50,7 @@ export function getExpressionMeta(type: string) { } function createExpressionName(type: string, existingExpressions: ExpressionData[]): string { - const base = type.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/\s+/g, "_").toLowerCase(); + const base = toExpressionNameBase(type); const taken = new Set(existingExpressions.map((expression) => expression.name)); let counter = 1; let candidate = `${base}_${counter}`; @@ -56,3 +60,12 @@ function createExpressionName(type: string, existingExpressions: ExpressionData[ } return candidate; } + +function toExpressionNameBase(type: string): string { + // Best-effort conversion from display names/types into stable snake_case keys. + return type + .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2") + .replace(/([a-z0-9])([A-Z])/g, "$1_$2") + .replace(/\s+/g, "_") + .toLowerCase(); +} diff --git a/ui/ids.ts b/ui/ids.ts new file mode 100644 index 0000000..bfc1698 --- /dev/null +++ b/ui/ids.ts @@ -0,0 +1,3 @@ +export function generateId(prefix: string): string { + return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; +} diff --git a/ui/palette.ts b/ui/palette.ts index 2a413e2..9749969 100644 --- a/ui/palette.ts +++ b/ui/palette.ts @@ -3,6 +3,7 @@ import { addNode, getState } from "./state"; import { NodeData } from "./types"; import { EXPRESSION_LIBRARY, createExpression } from "./expressionTemplates"; +import { generateId } from "./ids"; let paletteEl: HTMLDivElement; @@ -66,7 +67,7 @@ function addBlockFromTemplate(idx: number, position?: { x: number; y: number }) const template = EXPRESSION_LIBRARY[idx]; if (!template) return; - const id = `block_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; + const id = generateId("block"); const nodes = getState().nodes; // Auto-position if not specified diff --git a/ui/panel.ts b/ui/panel.ts index f9cd7ca..b3e113e 100644 --- a/ui/panel.ts +++ b/ui/panel.ts @@ -2,7 +2,8 @@ import { getState, updateNode, removeNode, subscribe, setState } from "./state"; import { ExpressionData, ForkBranchData, ForkData, NodeData } from "./types"; -import { EXPRESSION_LIBRARY, createExpression } from "./expressionTemplates"; +import { EXPRESSION_LIBRARY, createExpression, fallbackExpressionName } from "./expressionTemplates"; +import { generateId } from "./ids"; let panelEl: HTMLDivElement; let currentNodeId: string | null = null; @@ -122,7 +123,16 @@ function renderExpression(node: NodeData, expression: ExpressionData, expression
${escapeHtml(expression.type)} - +
-
Parents
@@ -483,21 +480,6 @@ function handleInput(event: Event) { const node = getCurrentNode(); if (!node) return; - if (target.dataset.field === "name") { - updateNode(node.id, { name: target.value }); - return; - } - - if (target.dataset.expressionField === "name") { - const expressionIndex = Number(target.dataset.expressionIndex); - updateNode(node.id, { - expressions: node.expressions.map((expression, index) => - index === expressionIndex ? { ...expression, name: target.value } : expression - ), - }); - return; - } - if (target.dataset.paramName) { const expressionIndex = Number(target.dataset.expressionIndex); const paramName = target.dataset.paramName; @@ -547,23 +529,14 @@ function handleChange(event: Event) { const node = getCurrentNode(); if (!node) return; - if (target.dataset.field === "id") { - renameNode(node, target.value.trim()); + if (target.dataset.field === "name") { + renameNodeDisplay(node, target.value); return; } if (target.dataset.expressionField === "name") { const expressionIndex = Number(target.dataset.expressionIndex); - const nextName = target.value.trim(); - if (!nextName) { - updateNode(node.id, { - expressions: node.expressions.map((expression, index) => - index === expressionIndex - ? { ...expression, name: fallbackExpressionName(String(expression.type || "expression"), expressionIndex) } - : expression - ), - }); - } + renameExpressionResult(node, expressionIndex, target.value); return; } @@ -603,31 +576,188 @@ function handleChange(event: Event) { } } -function renameNode(node: NodeData, newId: string) { - if (!newId || newId === node.id) return; +function renameNodeDisplay(node: NodeData, nextValue: string) { + const nextName = nextValue.trim() || node.name; + if (nextName === node.name) { + updateNode(node.id, { name: nextName }); + return; + } const { nodes } = getState(); - if (nodes.some((candidate) => candidate.id === newId)) return; + const renamedNodes = nodes.map((candidate) => (candidate.id === node.id ? { ...candidate, name: nextName } : candidate)); + applyReferenceAwareNodes(nodes, renamedNodes); +} - nodes.forEach((candidate) => { - if (candidate.id === node.id) return; +function renameExpressionResult(node: NodeData, expressionIndex: number, nextValue: string) { + const expression = node.expressions[expressionIndex]; + if (!expression) return; - const parentBlocks = candidate.parentBlocks.map((parentId) => (parentId === node.id ? newId : parentId)); - const forks = candidate.forks.map((fork) => ({ + const nextName = getNormalizedExpressionName(node, expressionIndex, nextValue); + if (nextName === expression.name) { + updateNode(node.id, { + expressions: node.expressions.map((candidate, index) => + index === expressionIndex ? { ...candidate, name: nextName } : candidate + ), + }); + return; + } + + const nextExpressions = node.expressions.map((candidate, index) => + index === expressionIndex ? { ...candidate, name: nextName } : candidate + ); + const { nodes } = getState(); + const renamedNodes = nodes.map((candidate) => + candidate.id === node.id ? { ...candidate, expressions: nextExpressions } : candidate + ); + applyReferenceAwareNodes(nodes, renamedNodes); +} + +function applyReferenceAwareNodes(previousNodes: NodeData[], nextNodes: NodeData[]) { + forcePanelRefresh(); + setState({ nodes: rewriteReferences(previousNodes, nextNodes) }); +} + +function rewriteReferences(previousNodes: NodeData[], nextNodes: NodeData[]): NodeData[] { + const previousBlockReferences = createBlockReferenceLookup(previousNodes); + const nextBlockReferences = createBlockReferenceLookup(nextNodes); + const blockChanges = previousNodes + .map((node) => ({ + previous: previousBlockReferences.get(node.id) ?? node.id, + next: nextBlockReferences.get(node.id) ?? node.id, + })) + .filter((change) => change.previous !== change.next); + + const expressionChanges = previousNodes.flatMap((previousNode) => { + const nextNode = nextNodes.find((candidate) => candidate.id === previousNode.id); + if (!nextNode) return []; + + const previousExpressions = createExpressionReferenceLookup(previousNode.expressions); + const nextExpressions = createExpressionReferenceLookup(nextNode.expressions); + const previousBlockKey = previousBlockReferences.get(previousNode.id) ?? previousNode.id; + const nextBlockKey = nextBlockReferences.get(previousNode.id) ?? previousNode.id; + + return previousNode.expressions + .map((expression) => { + const previousKey = previousExpressions.get(expression.id); + const nextKey = nextExpressions.get(expression.id); + if (!previousKey || !nextKey || previousKey === nextKey) return null; + return { previousBlockKey, nextBlockKey, previousKey, nextKey }; + }) + .filter((change): change is NonNullable => Boolean(change)); + }); + + if (blockChanges.length === 0 && expressionChanges.length === 0) { + return nextNodes; + } + + return nextNodes.map((node) => ({ + ...node, + expressions: node.expressions.map((expression) => ({ + ...expression, + parameters: expression.parameters.map((parameter) => ({ + ...parameter, + value: rewriteReferenceText(parameter.value, blockChanges, expressionChanges), + })), + })), + forks: node.forks.map((fork) => ({ ...fork, branches: fork.branches.map((branch) => ({ ...branch, - resultTrueBlocks: branch.resultTrueBlocks.map((targetId) => (targetId === node.id ? newId : targetId)), - resultFalseBlocks: branch.resultFalseBlocks.map((targetId) => (targetId === node.id ? newId : targetId)), + statement: rewriteBranchStatement(branch.statement, blockChanges, expressionChanges), })), - })); + })), + })); +} - updateNode(candidate.id, { parentBlocks, forks }); - }); +function rewriteBranchStatement( + statement: any, + blockChanges: ReferenceChange[], + expressionChanges: ExpressionReferenceChange[] +) { + const rewritten = rewriteReferenceText(stringifyStatement(statement), blockChanges, expressionChanges); + return typeof statement === "string" ? rewritten : parseStatementInput(rewritten); +} + +function rewriteReferenceText( + value: string, + blockChanges: ReferenceChange[], + expressionChanges: ExpressionReferenceChange[] +): string { + let nextValue = value; + + for (const change of blockChanges) { + nextValue = replaceAll(nextValue, `{{blocks.${change.previous}.`, `{{blocks.${change.next}.`); + nextValue = nextValue.replace(new RegExp(`\\$blocks\\.${escapeRegex(change.previous)}\\.`, "g"), `$blocks.${change.next}.`); + nextValue = replaceAll(nextValue, `{{${change.previous}.`, `{{${change.next}.`); + nextValue = nextValue.replace(new RegExp(`\\$${escapeRegex(change.previous)}\\.`, "g"), `$${change.next}.`); + } - updateNode(node.id, { id: newId }); - currentNodeId = newId; - setState({ selectedNodeId: newId }); + for (const change of expressionChanges) { + nextValue = replaceAll(nextValue, `{{current.${change.previousKey}}}`, `{{current.${change.nextKey}}}`); + nextValue = replaceAll(nextValue, `{{current.${change.previousKey}.`, `{{current.${change.nextKey}.`); + nextValue = nextValue.replace( + new RegExp(`\\$current\\.${escapeRegex(change.previousKey)}(?=\\b|\\.)`, "g"), + `$current.${change.nextKey}` + ); + + nextValue = replaceAll(nextValue, `{{blocks.${change.previousBlockKey}.${change.previousKey}}}`, `{{blocks.${change.nextBlockKey}.${change.nextKey}}}`); + nextValue = replaceAll(nextValue, `{{blocks.${change.previousBlockKey}.${change.previousKey}.`, `{{blocks.${change.nextBlockKey}.${change.nextKey}.`); + nextValue = nextValue.replace( + new RegExp(`\\$blocks\\.${escapeRegex(change.previousBlockKey)}\\.${escapeRegex(change.previousKey)}(?=\\b|\\.)`, "g"), + `$blocks.${change.nextBlockKey}.${change.nextKey}` + ); + + nextValue = replaceAll(nextValue, `{{${change.previousBlockKey}.${change.previousKey}}}`, `{{${change.nextBlockKey}.${change.nextKey}}}`); + nextValue = replaceAll(nextValue, `{{${change.previousBlockKey}.${change.previousKey}.`, `{{${change.nextBlockKey}.${change.nextKey}.`); + nextValue = nextValue.replace( + new RegExp(`\\$${escapeRegex(change.previousBlockKey)}\\.${escapeRegex(change.previousKey)}(?=\\b|\\.)`, "g"), + `$${change.nextBlockKey}.${change.nextKey}` + ); + + nextValue = replaceAll(nextValue, `{{${change.previousKey}}}`, `{{${change.nextKey}}}`); + nextValue = replaceAll(nextValue, `{{${change.previousKey}.`, `{{${change.nextKey}.`); + nextValue = nextValue.replace( + new RegExp(`(^|[^A-Za-z0-9_])\\$${escapeRegex(change.previousKey)}(?=\\b|\\.)`, "g"), + (_match, prefix: string) => `${prefix}$${change.nextKey}` + ); + } + + return nextValue; +} + +function createExpressionReferenceLookup(expressions: ExpressionData[]): Map { + const taken = new Set(); + const lookup = new Map(); + + for (const expression of expressions) { + const key = resolveExpressionReferenceKey(expression, taken); + taken.add(key); + lookup.set(expression.id, key); + } + + return lookup; +} + +function getNormalizedExpressionName(node: NodeData, expressionIndex: number, nextValue: string): string { + const expression = node.expressions[expressionIndex]; + const fallback = fallbackExpressionName(String(expression?.type || "expression"), expressionIndex); + const baseKey = toReferenceKey(nextValue.trim() || fallback, fallback); + const taken = node.expressions + .filter((_, index) => index !== expressionIndex) + .map((candidate) => candidate.name); + return ensureUniqueReferenceKey(baseKey, taken); +} + +interface ReferenceChange { + previous: string; + next: string; +} + +interface ExpressionReferenceChange { + previousBlockKey: string; + nextBlockKey: string; + previousKey: string; + nextKey: string; } function getCurrentNode(): NodeData | null { @@ -661,14 +791,15 @@ function getReferenceOptions(node: NodeData, expressionIndex: number) { const currentBlockReferences = node.expressions.slice(0, expressionIndex).map((expression) => ({ label: `current.${expression.name}`, template: `{{current.${expression.name}}}`, - forkToken: `$${expression.name}`, + forkToken: `$current.${expression.name}`, })); + const blockReferenceLookup = createBlockReferenceLookup(getState().nodes); const upstreamReferences = collectUpstreamNodes(node.id).flatMap((upstreamNode) => upstreamNode.expressions.map((expression) => ({ - label: `${upstreamNode.id}.${expression.name}`, - template: `{{${upstreamNode.id}.${expression.name}}}`, - forkToken: `$${upstreamNode.id}.${expression.name}`, + label: `${upstreamNode.name} → ${expression.name}`, + template: `{{blocks.${blockReferenceLookup.get(upstreamNode.id)}.${expression.name}}}`, + forkToken: `$blocks.${blockReferenceLookup.get(upstreamNode.id)}.${expression.name}`, })) ); @@ -676,17 +807,18 @@ function getReferenceOptions(node: NodeData, expressionIndex: number) { } function getForkReferenceOptions(node: NodeData) { + const blockReferenceLookup = createBlockReferenceLookup(getState().nodes); return [ ...node.expressions.map((expression) => ({ label: `current.${expression.name}`, template: `{{current.${expression.name}}}`, - forkToken: `$${expression.name}`, + forkToken: `$current.${expression.name}`, })), ...collectUpstreamNodes(node.id).flatMap((upstreamNode) => upstreamNode.expressions.map((expression) => ({ - label: `${upstreamNode.id}.${expression.name}`, - template: `{{${upstreamNode.id}.${expression.name}}}`, - forkToken: `$${upstreamNode.id}.${expression.name}`, + label: `${upstreamNode.name} → ${expression.name}`, + template: `{{blocks.${blockReferenceLookup.get(upstreamNode.id)}.${expression.name}}}`, + forkToken: `$blocks.${blockReferenceLookup.get(upstreamNode.id)}.${expression.name}`, })) ), ]; @@ -699,6 +831,8 @@ function collectUpstreamNodes(nodeId: string): NodeData[] { const upstreamNodes: NodeData[] = []; let pointer = 0; + // Use a manual pointer so newly discovered parents can be appended in-place + // without reallocating the queue on every breadth-first traversal step. while (pointer < queue.length) { const parentId = queue[pointer++]; if (!parentId || parentId === "workflow" || visited.has(parentId)) continue; @@ -766,11 +900,21 @@ function insertBranchReference(statement: any, reference: string): string { return JSON.stringify(["==", reference, ""]); } if (current.includes("$result")) { + // Replace only standalone `$result` tokens so placeholders like + // `$result_value` or `$results` keep their original meaning. return current.replace(/(^|[^A-Za-z0-9_])\$result(?=[^A-Za-z0-9_]|$)/g, (_, prefix: string) => `${prefix}${reference}`); } return appendReference(current, reference); } +function replaceAll(value: string, search: string, replacement: string): string { + return value.split(search).join(replacement); +} + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + function dedupeIds(ids: string[]): string[] { return Array.from(new Set(ids.filter(Boolean))); } diff --git a/ui/serializer.ts b/ui/serializer.ts index 2f02904..1a43ee7 100644 --- a/ui/serializer.ts +++ b/ui/serializer.ts @@ -3,6 +3,7 @@ import YAML from "yaml"; import { getState, setState } from "./state"; import { NodeData, ExpressionData, ForkData } from "./types"; +import { resolveExpressionReferenceKey } from "../workflow/referenceKeys"; export function serialize(): string { const { nodes, workflowId, workflowName } = getState(); @@ -46,16 +47,26 @@ export function deserialize(yamlStr: string) { if (!def) return; const nodes: NodeData[] = (def.blocks ?? []).map((block: any, index: number) => { - const expressions: ExpressionData[] = (block.expressions ?? []).map((expr: any) => ({ - id: expr.id || `expr_${Math.random().toString(36).slice(2, 6)}`, - name: expr.name || expr.id || expr.type?.toLowerCase?.() || "result", - type: expr.type, - parameters: (expr.parameters ?? []).map((p: any) => ({ - id: p.id || "", - name: p.name, - value: p.value ?? p.defaultValue ?? "", - })), - })); + const takenExpressionNames = new Set(); + const expressions: ExpressionData[] = (block.expressions ?? []).map((expr: any) => { + const id = expr.id || `expr_${Math.random().toString(36).slice(2, 6)}`; + const name = resolveExpressionReferenceKey( + { id, name: expr.name, type: expr.type }, + takenExpressionNames + ); + takenExpressionNames.add(name); + + return { + id, + name, + type: expr.type, + parameters: (expr.parameters ?? []).map((p: any) => ({ + id: p.id || "", + name: p.name, + value: p.value ?? p.defaultValue ?? "", + })), + }; + }); const forks: ForkData[] = (block.forks ?? []).map((fork: any) => ({ id: fork.id || `fork_${Math.random().toString(36).slice(2, 6)}`, diff --git a/workflow/expression.ts b/workflow/expression.ts index 2d3be17..2e99dd1 100644 --- a/workflow/expression.ts +++ b/workflow/expression.ts @@ -3,6 +3,7 @@ // A workflow block computation depends on the type of the workflow block expression. // Workflow block expression types: Math, Data, and Control. import { evaluate } from "mathjs"; +import { toReferenceKey } from "./referenceKeys"; /** * There are 3 WorkflowBlockExpressionTypes: Math, Data, and Control. @@ -137,6 +138,15 @@ export class WorkflowExpression { throw new Error('Unknown workflow expression type: ' + obj.type); } + + protected createResult(type: WorkflowExpressionResultType, value: any): WorkflowExpressionResult { + const resultKey = this.getResultKey(); + return new WorkflowExpressionResult(this.id || resultKey, resultKey, type, value?.toString?.() ?? String(value)); + } + + protected getResultKey(): string { + return toReferenceKey(this.name || this.id || "expression", "expression"); + } } // WorkflowBlockExpressionMath is a workflow block expression that performs a mathematical operation. @@ -158,7 +168,7 @@ export class WorkflowExpressionMath extends WorkflowExpression { if (this.withResult) { result = await this.withResult.compute(result); } - return new WorkflowExpressionResult(this.id || this.name || 'expression', this.name || this.id || 'expression', WorkflowExpressionResultType.String, result.toString()); + return this.createResult(WorkflowExpressionResultType.String, result); } } @@ -182,7 +192,7 @@ export class WorkflowExpressionConsoleLog extends WorkflowExpression { result = await this.withResult.compute(result); } console.log(result); - return new WorkflowExpressionResult(this.id || this.name || 'expression', this.name || this.id || 'expression', WorkflowExpressionResultType.String, result.toString()); + return this.createResult(WorkflowExpressionResultType.String, result); } } @@ -206,7 +216,7 @@ export class WorkflowExpressionWait extends WorkflowExpression { result = await this.withResult.compute(result); } await new Promise(resolve => setTimeout(resolve, result * 1000)); - return new WorkflowExpressionResult(this.id || this.name || 'expression', this.name || this.id || 'expression', WorkflowExpressionResultType.String, result.toString()); + return this.createResult(WorkflowExpressionResultType.String, result); } } @@ -237,7 +247,7 @@ export class WorkflowExpressionHTTPRequest extends WorkflowExpression { } else { result = JSON.stringify(json); } - return new WorkflowExpressionResult(this.id || this.name || 'expression', this.name || this.id || 'expression', WorkflowExpressionResultType.String, result.toString()); + return this.createResult(WorkflowExpressionResultType.String, result); } } @@ -256,5 +266,5 @@ export class WorkflowExpressionResult { } function resolveExpressionName(obj: { id?: string; name?: string; type?: string }): string { - return obj.name || obj.id || (obj.type ? String(obj.type).toLowerCase() : '') || 'expression'; + return toReferenceKey(obj.name || obj.id || obj.type || "expression", "expression"); } diff --git a/workflow/parser.ts b/workflow/parser.ts index 9b08a1a..9746b0c 100644 --- a/workflow/parser.ts +++ b/workflow/parser.ts @@ -2,6 +2,7 @@ import { Workflow } from "./index"; import { WorkflowBlock } from "./block"; import { WorkflowExpression, WorkflowExpressionParameter } from "./expression"; import { WorkflowFork } from "./fork"; +import { resolveExpressionReferenceKey } from "./referenceKeys"; // The Parser class parses a workflow definition and returns a Workflow object. @@ -20,11 +21,20 @@ export class WorkflowParser { let parentBlocks: string[] = blockDefinition.parentBlocks ?? []; // parse expressions - let expressions: WorkflowExpression[] = (blockDefinition.expressions ?? []).map((expressionDefinition: any, index: number) => - WorkflowExpression.fromObject({ + const takenExpressionNames = new Set(); + let expressions: WorkflowExpression[] = (blockDefinition.expressions ?? []).map((expressionDefinition: any) => { + const expressionName = resolveExpressionReferenceKey({ + id: expressionDefinition.id, + name: expressionDefinition.name, + type: expressionDefinition.type, + }, takenExpressionNames); + takenExpressionNames.add(expressionName); + + return WorkflowExpression.fromObject({ ...expressionDefinition, - name: expressionDefinition.name || expressionDefinition.id || `${String(expressionDefinition.type || 'expression').toLowerCase()}_${index + 1}`, - })); + name: expressionName, + }); + }); // parse forks let forks: WorkflowFork[] = (blockDefinition.forks ?? []).map((forkDefinition: any) => diff --git a/workflow/referenceKeys.ts b/workflow/referenceKeys.ts new file mode 100644 index 0000000..8373842 --- /dev/null +++ b/workflow/referenceKeys.ts @@ -0,0 +1,66 @@ +export interface ReferenceSource { + id?: string; + name?: string; + type?: string; +} + +export function toReferenceKey(value: string | undefined | null, fallback = "value"): string { + const normalized = normalizeReferenceValue(value); + if (normalized) { + return normalized; + } + + const fallbackNormalized = normalizeReferenceValue(fallback); + return fallbackNormalized || "value"; +} + +export function ensureUniqueReferenceKey(baseKey: string, taken: Iterable): string { + const normalizedBaseKey = toReferenceKey(baseKey, "value"); + const used = new Set(taken); + + if (!used.has(normalizedBaseKey)) { + return normalizedBaseKey; + } + + let counter = 2; + let candidate = `${normalizedBaseKey}_${counter}`; + while (used.has(candidate)) { + counter += 1; + candidate = `${normalizedBaseKey}_${counter}`; + } + return candidate; +} + +export function resolveExpressionReferenceKey(source: ReferenceSource, taken: Iterable = []): string { + const fallback = source.type ? toReferenceKey(source.type, "expression") : "expression"; + const baseKey = toReferenceKey(source.name || source.id || fallback, fallback); + return ensureUniqueReferenceKey(baseKey, taken); +} + +export function createBlockReferenceLookup(blocks: T[]): Map { + const taken = new Set(); + const lookup = new Map(); + + for (const block of blocks) { + const key = ensureUniqueReferenceKey(toReferenceKey(block.name || block.id, "block"), taken); + taken.add(key); + lookup.set(block.id, key); + } + + return lookup; +} + +function normalizeReferenceValue(value: string | undefined | null): string { + const normalized = String(value ?? "") + .trim() + .replace(/([a-z0-9])([A-Z])/g, "$1_$2") + .replace(/[^A-Za-z0-9]+/g, "_") + .replace(/^_+|_+$/g, "") + .toLowerCase(); + + if (!normalized) { + return ""; + } + + return /^\d/.test(normalized) ? `n_${normalized}` : normalized; +} diff --git a/workflow/runner.ts b/workflow/runner.ts index 4cb4823..6c3cbc6 100644 --- a/workflow/runner.ts +++ b/workflow/runner.ts @@ -5,6 +5,7 @@ import { Workflow, WorkflowResult } from "./index"; import { WorkflowBlockResult, WorkflowBlock } from "./block"; import { WorkflowExpression, WorkflowExpressionResult } from "./expression"; +import { createBlockReferenceLookup, toReferenceKey } from "./referenceKeys"; export enum BlockState { NotStarted, @@ -17,12 +18,14 @@ export class WorkflowRunner { public workflow: Workflow; public onBlockFinished: (block: WorkflowBlock, result: WorkflowBlockResult) => void = (block: WorkflowBlock, result: WorkflowBlockResult) => { }; private state: { [key: string]: BlockState }; + private readonly blockReferenceKeys: Map; private static MAX_DEPTH = 100; constructor(workflow: Workflow) { this.workflow = workflow; this.state = {}; + this.blockReferenceKeys = createBlockReferenceLookup(workflow.blocks); } public async run(): Promise { @@ -69,10 +72,11 @@ export class WorkflowRunner { private async executeBlock(block: WorkflowBlock, context: ExecutionContext): Promise { this.setBlockState(block, BlockState.Running); const expressionsResults = await this.evaluateExpressions(block.expressions, context); - // Flatten the expressionsResults into a single object with all of the results.name and results.value properties const resultsObject: { [key: string]: any } = {}; for (const result of expressionsResults) { resultsObject[result.name] = result.value; + const normalizedResultKey = toReferenceKey(result.name, result.id || "expression"); + resultsObject[normalizedResultKey] = result.value; } // Keep a stable `result` alias for existing forks/samples while letting // richer blocks expose additional named expression outputs. @@ -91,6 +95,7 @@ export class WorkflowRunner { const result = await expression.compute(scopedContext); results.push(result); currentResults[result.name] = result.value; + currentResults[toReferenceKey(result.name, result.id || "expression")] = result.value; // Keep `result` pointed at the latest expression output so later // expressions can reference the most recent value with {{result}}. currentResults.result = result.value; @@ -98,9 +103,9 @@ export class WorkflowRunner { return results; } - private evaluateFork(block: WorkflowBlock, results: WorkflowBlockResult, context: ExecutionContext): WorkflowBlock[] { + private evaluateFork(block: WorkflowBlock, blockResult: WorkflowBlockResult, context: ExecutionContext): WorkflowBlock[] { const blocksToExecute: WorkflowBlock[] = []; - const resultsObject = this.createScopedContext(context, results.value); + const resultsObject = this.createScopedContext(context, blockResult.value); // for each fork, evaluate them and return the results for (const fork of block.forks) { // Evaluate the fork @@ -137,10 +142,12 @@ export class WorkflowRunner { private extendContext(context: ExecutionContext, block: WorkflowBlock, result: WorkflowBlockResult): ExecutionContext { const blockResults = result.value; + const blockReferenceKey = this.blockReferenceKeys.get(block.id) ?? block.id; return { blocks: { ...context.blocks, [block.id]: blockResults, + [blockReferenceKey]: blockResults, }, lastResult: blockResults.result, }; From 67178156ef384acd80c016d9244f8c4039ddee43 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 27 Jun 2026 06:03:45 +0000 Subject: [PATCH 4/7] fix: address validation follow-up comments --- ui/connections.ts | 10 +++++++++- ui/expressionTemplates.ts | 2 +- ui/panel.ts | 5 ++++- workflow/expression.ts | 18 +++++++++++++++++- workflow/referenceKeys.ts | 2 ++ workflow/runner.ts | 7 ++++--- 6 files changed, 37 insertions(+), 7 deletions(-) diff --git a/ui/connections.ts b/ui/connections.ts index 37fbf15..8e50b12 100644 --- a/ui/connections.ts +++ b/ui/connections.ts @@ -2,8 +2,16 @@ import { NodeData, NodeExecutionState } from "./types"; -const NODE_WIDTH = () => +let cachedNodeWidth = typeof window !== "undefined" && window.innerWidth <= 768 ? Math.min(180, window.innerWidth - 32) : 200; + +if (typeof window !== "undefined") { + window.addEventListener("resize", () => { + cachedNodeWidth = window.innerWidth <= 768 ? Math.min(180, window.innerWidth - 32) : 200; + }); +} + +const NODE_WIDTH = () => cachedNodeWidth; const NODE_HEIGHT = 80; export function renderConnections( diff --git a/ui/expressionTemplates.ts b/ui/expressionTemplates.ts index 5903b1d..7d250e6 100644 --- a/ui/expressionTemplates.ts +++ b/ui/expressionTemplates.ts @@ -53,5 +53,5 @@ export function getExpressionMeta(type: string) { function createExpressionName(type: string, existingExpressions: ExpressionData[]): string { const taken = new Set(existingExpressions.map((expression) => expression.name)); const base = resolveExpressionReferenceKey({ type }, []); - return ensureUniqueReferenceKey(`${base}_1`, taken); + return ensureUniqueReferenceKey(base, taken); } diff --git a/ui/panel.ts b/ui/panel.ts index 3d99332..5bc4da2 100644 --- a/ui/panel.ts +++ b/ui/panel.ts @@ -686,6 +686,8 @@ function rewriteReferenceText( let nextValue = value; for (const change of blockChanges) { + // `{{...}}` references are template interpolations inside expression inputs, + // while `$...` references are fork tokens evaluated by the branching engine. nextValue = replaceAll(nextValue, `{{blocks.${change.previous}.`, `{{blocks.${change.next}.`); nextValue = nextValue.replace(new RegExp(`\\$blocks\\.${escapeRegex(change.previous)}\\.`, "g"), `$blocks.${change.next}.`); nextValue = replaceAll(nextValue, `{{${change.previous}.`, `{{${change.next}.`); @@ -832,7 +834,7 @@ function collectUpstreamNodes(nodeId: string): NodeData[] { let pointer = 0; // Use a manual pointer so newly discovered parents can be appended in-place - // without reallocating the queue on every breadth-first traversal step. + // as the breadth-first search uncovers more ancestors. while (pointer < queue.length) { const parentId = queue[pointer++]; if (!parentId || parentId === "workflow" || visited.has(parentId)) continue; @@ -912,6 +914,7 @@ function replaceAll(value: string, search: string, replacement: string): string } function escapeRegex(value: string): string { + // Escape user-facing keys before building dynamic RegExp objects. return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } diff --git a/workflow/expression.ts b/workflow/expression.ts index 2e99dd1..8241b7f 100644 --- a/workflow/expression.ts +++ b/workflow/expression.ts @@ -141,7 +141,7 @@ export class WorkflowExpression { protected createResult(type: WorkflowExpressionResultType, value: any): WorkflowExpressionResult { const resultKey = this.getResultKey(); - return new WorkflowExpressionResult(this.id || resultKey, resultKey, type, value?.toString?.() ?? String(value)); + return new WorkflowExpressionResult(this.id || resultKey, resultKey, type, stringifyResultValue(value)); } protected getResultKey(): string { @@ -268,3 +268,19 @@ export class WorkflowExpressionResult { function resolveExpressionName(obj: { id?: string; name?: string; type?: string }): string { return toReferenceKey(obj.name || obj.id || obj.type || "expression", "expression"); } + +function stringifyResultValue(value: any): string { + if (typeof value === "string") { + return value; + } + + if (value != null && typeof value === "object") { + try { + return JSON.stringify(value); + } catch { + return String(value); + } + } + + return String(value); +} diff --git a/workflow/referenceKeys.ts b/workflow/referenceKeys.ts index 8373842..281afe1 100644 --- a/workflow/referenceKeys.ts +++ b/workflow/referenceKeys.ts @@ -53,6 +53,7 @@ export function createBlockReferenceLookup Date: Sat, 27 Jun 2026 06:05:13 +0000 Subject: [PATCH 5/7] fix: resolve remaining validation comments --- ui/canvas.ts | 2 +- ui/connections.ts | 18 ++++++++++++++---- ui/panel.ts | 39 ++++++++++++++++++++------------------- workflow/referenceKeys.ts | 4 +++- workflow/runner.ts | 6 ++++-- 5 files changed, 42 insertions(+), 27 deletions(-) diff --git a/ui/canvas.ts b/ui/canvas.ts index 097a932..d54bc8a 100644 --- a/ui/canvas.ts +++ b/ui/canvas.ts @@ -91,7 +91,7 @@ function bindPanEvents(container: HTMLDivElement) { } if (dragNodeId) { dragNodeId = null; - if (dragPointerId != null && container.hasPointerCapture(dragPointerId)) { + if (dragPointerId !== null && container.hasPointerCapture(dragPointerId)) { container.releasePointerCapture(dragPointerId); } dragPointerId = null; diff --git a/ui/connections.ts b/ui/connections.ts index 8e50b12..4be1ec3 100644 --- a/ui/connections.ts +++ b/ui/connections.ts @@ -2,16 +2,26 @@ import { NodeData, NodeExecutionState } from "./types"; +const MOBILE_BREAKPOINT = 768; +const MOBILE_NODE_MARGIN = 32; +const MOBILE_NODE_MAX_WIDTH = 180; +const DESKTOP_NODE_WIDTH = 200; + let cachedNodeWidth = - typeof window !== "undefined" && window.innerWidth <= 768 ? Math.min(180, window.innerWidth - 32) : 200; + typeof window !== "undefined" && window.innerWidth <= MOBILE_BREAKPOINT + ? Math.min(MOBILE_NODE_MAX_WIDTH, window.innerWidth - MOBILE_NODE_MARGIN) + : DESKTOP_NODE_WIDTH; if (typeof window !== "undefined") { window.addEventListener("resize", () => { - cachedNodeWidth = window.innerWidth <= 768 ? Math.min(180, window.innerWidth - 32) : 200; + cachedNodeWidth = + window.innerWidth <= MOBILE_BREAKPOINT + ? Math.min(MOBILE_NODE_MAX_WIDTH, window.innerWidth - MOBILE_NODE_MARGIN) + : DESKTOP_NODE_WIDTH; }); } -const NODE_WIDTH = () => cachedNodeWidth; +const getNodeWidth = () => cachedNodeWidth; const NODE_HEIGHT = 80; export function renderConnections( @@ -23,7 +33,7 @@ export function renderConnections( svg.innerHTML = ""; const nodeMap = new Map(nodes.map((n) => [n.id, n])); - const nodeWidth = NODE_WIDTH(); + const nodeWidth = getNodeWidth(); for (const node of nodes) { // Parent → child connections diff --git a/ui/panel.ts b/ui/panel.ts index 5bc4da2..0728d39 100644 --- a/ui/panel.ts +++ b/ui/panel.ts @@ -8,6 +8,7 @@ import { createBlockReferenceLookup, ensureUniqueReferenceKey, resolveExpression let panelEl: HTMLDivElement; let currentNodeId: string | null = null; +const STANDALONE_RESULT_TOKEN = /(^|[^A-Za-z0-9_])\$result(?=[^A-Za-z0-9_]|$)/g; export function initPanel(container: HTMLElement) { panelEl = document.createElement("div"); @@ -688,40 +689,40 @@ function rewriteReferenceText( for (const change of blockChanges) { // `{{...}}` references are template interpolations inside expression inputs, // while `$...` references are fork tokens evaluated by the branching engine. + const blockForkPattern = new RegExp(`\\$blocks\\.${escapeRegex(change.previous)}\\.`, "g"); + const legacyBlockForkPattern = new RegExp(`\\$${escapeRegex(change.previous)}\\.`, "g"); nextValue = replaceAll(nextValue, `{{blocks.${change.previous}.`, `{{blocks.${change.next}.`); - nextValue = nextValue.replace(new RegExp(`\\$blocks\\.${escapeRegex(change.previous)}\\.`, "g"), `$blocks.${change.next}.`); + nextValue = nextValue.replace(blockForkPattern, `$blocks.${change.next}.`); nextValue = replaceAll(nextValue, `{{${change.previous}.`, `{{${change.next}.`); - nextValue = nextValue.replace(new RegExp(`\\$${escapeRegex(change.previous)}\\.`, "g"), `$${change.next}.`); + nextValue = nextValue.replace(legacyBlockForkPattern, `$${change.next}.`); } for (const change of expressionChanges) { + const currentForkPattern = new RegExp(`\\$current\\.${escapeRegex(change.previousKey)}(?=\\b|\\.)`, "g"); + const blockForkPattern = new RegExp( + `\\$blocks\\.${escapeRegex(change.previousBlockKey)}\\.${escapeRegex(change.previousKey)}(?=\\b|\\.)`, + "g" + ); + const legacyBlockForkPattern = new RegExp( + `\\$${escapeRegex(change.previousBlockKey)}\\.${escapeRegex(change.previousKey)}(?=\\b|\\.)`, + "g" + ); + const legacyCurrentForkPattern = new RegExp(`(^|[^A-Za-z0-9_])\\$${escapeRegex(change.previousKey)}(?=\\b|\\.)`, "g"); nextValue = replaceAll(nextValue, `{{current.${change.previousKey}}}`, `{{current.${change.nextKey}}}`); nextValue = replaceAll(nextValue, `{{current.${change.previousKey}.`, `{{current.${change.nextKey}.`); - nextValue = nextValue.replace( - new RegExp(`\\$current\\.${escapeRegex(change.previousKey)}(?=\\b|\\.)`, "g"), - `$current.${change.nextKey}` - ); + nextValue = nextValue.replace(currentForkPattern, `$current.${change.nextKey}`); nextValue = replaceAll(nextValue, `{{blocks.${change.previousBlockKey}.${change.previousKey}}}`, `{{blocks.${change.nextBlockKey}.${change.nextKey}}}`); nextValue = replaceAll(nextValue, `{{blocks.${change.previousBlockKey}.${change.previousKey}.`, `{{blocks.${change.nextBlockKey}.${change.nextKey}.`); - nextValue = nextValue.replace( - new RegExp(`\\$blocks\\.${escapeRegex(change.previousBlockKey)}\\.${escapeRegex(change.previousKey)}(?=\\b|\\.)`, "g"), - `$blocks.${change.nextBlockKey}.${change.nextKey}` - ); + nextValue = nextValue.replace(blockForkPattern, `$blocks.${change.nextBlockKey}.${change.nextKey}`); nextValue = replaceAll(nextValue, `{{${change.previousBlockKey}.${change.previousKey}}}`, `{{${change.nextBlockKey}.${change.nextKey}}}`); nextValue = replaceAll(nextValue, `{{${change.previousBlockKey}.${change.previousKey}.`, `{{${change.nextBlockKey}.${change.nextKey}.`); - nextValue = nextValue.replace( - new RegExp(`\\$${escapeRegex(change.previousBlockKey)}\\.${escapeRegex(change.previousKey)}(?=\\b|\\.)`, "g"), - `$${change.nextBlockKey}.${change.nextKey}` - ); + nextValue = nextValue.replace(legacyBlockForkPattern, `$${change.nextBlockKey}.${change.nextKey}`); nextValue = replaceAll(nextValue, `{{${change.previousKey}}}`, `{{${change.nextKey}}}`); nextValue = replaceAll(nextValue, `{{${change.previousKey}.`, `{{${change.nextKey}.`); - nextValue = nextValue.replace( - new RegExp(`(^|[^A-Za-z0-9_])\\$${escapeRegex(change.previousKey)}(?=\\b|\\.)`, "g"), - (_match, prefix: string) => `${prefix}$${change.nextKey}` - ); + nextValue = nextValue.replace(legacyCurrentForkPattern, (_match, prefix: string) => `${prefix}$${change.nextKey}`); } return nextValue; @@ -904,7 +905,7 @@ function insertBranchReference(statement: any, reference: string): string { if (current.includes("$result")) { // Replace only standalone `$result` tokens so placeholders like // `$result_value` or `$results` keep their original meaning. - return current.replace(/(^|[^A-Za-z0-9_])\$result(?=[^A-Za-z0-9_]|$)/g, (_, prefix: string) => `${prefix}${reference}`); + return current.replace(STANDALONE_RESULT_TOKEN, (_, prefix: string) => `${prefix}${reference}`); } return appendReference(current, reference); } diff --git a/workflow/referenceKeys.ts b/workflow/referenceKeys.ts index 281afe1..4c7b816 100644 --- a/workflow/referenceKeys.ts +++ b/workflow/referenceKeys.ts @@ -4,6 +4,8 @@ export interface ReferenceSource { type?: string; } +const NUMERIC_REFERENCE_PREFIX = "n_"; + export function toReferenceKey(value: string | undefined | null, fallback = "value"): string { const normalized = normalizeReferenceValue(value); if (normalized) { @@ -64,5 +66,5 @@ function normalizeReferenceValue(value: string | undefined | null): string { } // Prefix digit-starting names so they remain valid reference identifiers. - return /^\d/.test(normalized) ? `n_${normalized}` : normalized; + return /^\d/.test(normalized) ? `${NUMERIC_REFERENCE_PREFIX}${normalized}` : normalized; } diff --git a/workflow/runner.ts b/workflow/runner.ts index 9eb4089..2a8f634 100644 --- a/workflow/runner.ts +++ b/workflow/runner.ts @@ -59,6 +59,8 @@ export class WorkflowRunner { this.setBlockState(block, BlockState.Finished, result); const nextContext = this.extendContext(context, block, result); + // A block can be reachable via both a direct parent edge and a fork + // branch, so collapse duplicates before recursing further. const nextBlocks = this.dedupeBlocks([...this.findChildBlocks(block), ...this.evaluateFork(block, result, nextContext)]); const nestedResults = await Promise.all(nextBlocks.map(nextBlock => this.executeBlockTree(nextBlock, depth + 1, nextContext))); @@ -79,8 +81,8 @@ export class WorkflowRunner { resultsObject[normalizedResultKey] = result.value; } // Keep a stable `result` alias for existing forks/samples while letting - // richer blocks expose additional named expression outputs. The extra - // guard avoids clobbering an explicit expression named `result`. + // richer blocks expose additional named expression outputs. Explicit + // `result` expressions take precedence over this implicit fallback. const lastExpressionResult = expressionsResults[expressionsResults.length - 1]; if (lastExpressionResult && lastExpressionResult.name !== "result" && !("result" in resultsObject)) { resultsObject.result = lastExpressionResult.value; From f9b0c3fd587b0ba749921a3d47eb283cd7fe36b9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 27 Jun 2026 06:06:57 +0000 Subject: [PATCH 6/7] fix: polish validation cleanup --- styles.css | 1 + ui/canvas.ts | 2 +- ui/connections.ts | 16 +++---- ui/panel.ts | 88 +++++++++++++++++++++++---------------- workflow/referenceKeys.ts | 1 + workflow/runner.ts | 8 ++-- 6 files changed, 68 insertions(+), 48 deletions(-) diff --git a/styles.css b/styles.css index 616c58a..ebdab05 100644 --- a/styles.css +++ b/styles.css @@ -125,6 +125,7 @@ button, select, input { right: 0.75rem; bottom: 0.75rem; width: auto; + max-height: min(68vh, 560px); max-height: min(68dvh, 560px); border: 1px solid var(--border); border-top: 1px solid var(--border); diff --git a/ui/canvas.ts b/ui/canvas.ts index d54bc8a..085bf20 100644 --- a/ui/canvas.ts +++ b/ui/canvas.ts @@ -91,7 +91,7 @@ function bindPanEvents(container: HTMLDivElement) { } if (dragNodeId) { dragNodeId = null; - if (dragPointerId !== null && container.hasPointerCapture(dragPointerId)) { + if (dragPointerId !== null) { container.releasePointerCapture(dragPointerId); } dragPointerId = null; diff --git a/ui/connections.ts b/ui/connections.ts index 4be1ec3..0a84e37 100644 --- a/ui/connections.ts +++ b/ui/connections.ts @@ -7,23 +7,23 @@ const MOBILE_NODE_MARGIN = 32; const MOBILE_NODE_MAX_WIDTH = 180; const DESKTOP_NODE_WIDTH = 200; -let cachedNodeWidth = - typeof window !== "undefined" && window.innerWidth <= MOBILE_BREAKPOINT - ? Math.min(MOBILE_NODE_MAX_WIDTH, window.innerWidth - MOBILE_NODE_MARGIN) - : DESKTOP_NODE_WIDTH; +let cachedNodeWidth = typeof window !== "undefined" ? calculateNodeWidth(window.innerWidth) : DESKTOP_NODE_WIDTH; if (typeof window !== "undefined") { window.addEventListener("resize", () => { - cachedNodeWidth = - window.innerWidth <= MOBILE_BREAKPOINT - ? Math.min(MOBILE_NODE_MAX_WIDTH, window.innerWidth - MOBILE_NODE_MARGIN) - : DESKTOP_NODE_WIDTH; + cachedNodeWidth = calculateNodeWidth(window.innerWidth); }); } const getNodeWidth = () => cachedNodeWidth; const NODE_HEIGHT = 80; +function calculateNodeWidth(windowWidth: number): number { + return windowWidth <= MOBILE_BREAKPOINT + ? Math.min(MOBILE_NODE_MAX_WIDTH, windowWidth - MOBILE_NODE_MARGIN) + : DESKTOP_NODE_WIDTH; +} + export function renderConnections( svg: SVGSVGElement, nodes: NodeData[], diff --git a/ui/panel.ts b/ui/panel.ts index 0728d39..b82dddd 100644 --- a/ui/panel.ts +++ b/ui/panel.ts @@ -685,44 +685,62 @@ function rewriteReferenceText( expressionChanges: ExpressionReferenceChange[] ): string { let nextValue = value; + const compiledBlockChanges = blockChanges.map((change) => ({ + ...change, + blockForkPattern: new RegExp(`\\$blocks\\.${escapeRegex(change.previous)}\\.`, "g"), + legacyBlockForkPattern: new RegExp(`\\$${escapeRegex(change.previous)}\\.`, "g"), + })); + const compiledExpressionChanges = expressionChanges.map((change) => ({ + ...change, + currentForkPattern: new RegExp(`\\$current\\.${escapeRegex(change.previousKey)}(?=\\b|\\.)`, "g"), + blockForkPattern: new RegExp( + `\\$blocks\\.${escapeRegex(change.previousBlockKey)}\\.${escapeRegex(change.previousKey)}(?=\\b|\\.)`, + "g" + ), + legacyBlockForkPattern: new RegExp( + `\\$${escapeRegex(change.previousBlockKey)}\\.${escapeRegex(change.previousKey)}(?=\\b|\\.)`, + "g" + ), + legacyCurrentForkPattern: new RegExp(`(^|[^A-Za-z0-9_])\\$${escapeRegex(change.previousKey)}(?=\\b|\\.)`, "g"), + })); - for (const change of blockChanges) { + for (const change of compiledBlockChanges) { // `{{...}}` references are template interpolations inside expression inputs, // while `$...` references are fork tokens evaluated by the branching engine. - const blockForkPattern = new RegExp(`\\$blocks\\.${escapeRegex(change.previous)}\\.`, "g"); - const legacyBlockForkPattern = new RegExp(`\\$${escapeRegex(change.previous)}\\.`, "g"); - nextValue = replaceAll(nextValue, `{{blocks.${change.previous}.`, `{{blocks.${change.next}.`); - nextValue = nextValue.replace(blockForkPattern, `$blocks.${change.next}.`); - nextValue = replaceAll(nextValue, `{{${change.previous}.`, `{{${change.next}.`); - nextValue = nextValue.replace(legacyBlockForkPattern, `$${change.next}.`); + nextValue = nextValue.replaceAll(`{{blocks.${change.previous}.`, `{{blocks.${change.next}.`); + nextValue = nextValue.replace(change.blockForkPattern, `$blocks.${change.next}.`); + nextValue = nextValue.replaceAll(`{{${change.previous}.`, `{{${change.next}.`); + nextValue = nextValue.replace(change.legacyBlockForkPattern, `$${change.next}.`); } - for (const change of expressionChanges) { - const currentForkPattern = new RegExp(`\\$current\\.${escapeRegex(change.previousKey)}(?=\\b|\\.)`, "g"); - const blockForkPattern = new RegExp( - `\\$blocks\\.${escapeRegex(change.previousBlockKey)}\\.${escapeRegex(change.previousKey)}(?=\\b|\\.)`, - "g" + for (const change of compiledExpressionChanges) { + nextValue = nextValue.replaceAll(`{{current.${change.previousKey}}}`, `{{current.${change.nextKey}}}`); + nextValue = nextValue.replaceAll(`{{current.${change.previousKey}.`, `{{current.${change.nextKey}.`); + nextValue = nextValue.replace(change.currentForkPattern, `$current.${change.nextKey}`); + + nextValue = nextValue.replaceAll( + `{{blocks.${change.previousBlockKey}.${change.previousKey}}}`, + `{{blocks.${change.nextBlockKey}.${change.nextKey}}}` ); - const legacyBlockForkPattern = new RegExp( - `\\$${escapeRegex(change.previousBlockKey)}\\.${escapeRegex(change.previousKey)}(?=\\b|\\.)`, - "g" + nextValue = nextValue.replaceAll( + `{{blocks.${change.previousBlockKey}.${change.previousKey}.`, + `{{blocks.${change.nextBlockKey}.${change.nextKey}.` + ); + nextValue = nextValue.replace(change.blockForkPattern, `$blocks.${change.nextBlockKey}.${change.nextKey}`); + + nextValue = nextValue.replaceAll( + `{{${change.previousBlockKey}.${change.previousKey}}}`, + `{{${change.nextBlockKey}.${change.nextKey}}}` ); - const legacyCurrentForkPattern = new RegExp(`(^|[^A-Za-z0-9_])\\$${escapeRegex(change.previousKey)}(?=\\b|\\.)`, "g"); - nextValue = replaceAll(nextValue, `{{current.${change.previousKey}}}`, `{{current.${change.nextKey}}}`); - nextValue = replaceAll(nextValue, `{{current.${change.previousKey}.`, `{{current.${change.nextKey}.`); - nextValue = nextValue.replace(currentForkPattern, `$current.${change.nextKey}`); - - nextValue = replaceAll(nextValue, `{{blocks.${change.previousBlockKey}.${change.previousKey}}}`, `{{blocks.${change.nextBlockKey}.${change.nextKey}}}`); - nextValue = replaceAll(nextValue, `{{blocks.${change.previousBlockKey}.${change.previousKey}.`, `{{blocks.${change.nextBlockKey}.${change.nextKey}.`); - nextValue = nextValue.replace(blockForkPattern, `$blocks.${change.nextBlockKey}.${change.nextKey}`); - - nextValue = replaceAll(nextValue, `{{${change.previousBlockKey}.${change.previousKey}}}`, `{{${change.nextBlockKey}.${change.nextKey}}}`); - nextValue = replaceAll(nextValue, `{{${change.previousBlockKey}.${change.previousKey}.`, `{{${change.nextBlockKey}.${change.nextKey}.`); - nextValue = nextValue.replace(legacyBlockForkPattern, `$${change.nextBlockKey}.${change.nextKey}`); - - nextValue = replaceAll(nextValue, `{{${change.previousKey}}}`, `{{${change.nextKey}}}`); - nextValue = replaceAll(nextValue, `{{${change.previousKey}.`, `{{${change.nextKey}.`); - nextValue = nextValue.replace(legacyCurrentForkPattern, (_match, prefix: string) => `${prefix}$${change.nextKey}`); + nextValue = nextValue.replaceAll( + `{{${change.previousBlockKey}.${change.previousKey}.`, + `{{${change.nextBlockKey}.${change.nextKey}.` + ); + nextValue = nextValue.replace(change.legacyBlockForkPattern, `$${change.nextBlockKey}.${change.nextKey}`); + + nextValue = nextValue.replaceAll(`{{${change.previousKey}}}`, `{{${change.nextKey}}}`); + nextValue = nextValue.replaceAll(`{{${change.previousKey}.`, `{{${change.nextKey}.`); + nextValue = nextValue.replace(change.legacyCurrentForkPattern, (_match, prefix: string) => `${prefix}$${change.nextKey}`); } return nextValue; @@ -902,18 +920,16 @@ function insertBranchReference(statement: any, reference: string): string { if (!current.trim()) { return JSON.stringify(["==", reference, ""]); } - if (current.includes("$result")) { + STANDALONE_RESULT_TOKEN.lastIndex = 0; + if (STANDALONE_RESULT_TOKEN.test(current)) { // Replace only standalone `$result` tokens so placeholders like // `$result_value` or `$results` keep their original meaning. + STANDALONE_RESULT_TOKEN.lastIndex = 0; return current.replace(STANDALONE_RESULT_TOKEN, (_, prefix: string) => `${prefix}${reference}`); } return appendReference(current, reference); } -function replaceAll(value: string, search: string, replacement: string): string { - return value.split(search).join(replacement); -} - function escapeRegex(value: string): string { // Escape user-facing keys before building dynamic RegExp objects. return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); diff --git a/workflow/referenceKeys.ts b/workflow/referenceKeys.ts index 4c7b816..01d23cb 100644 --- a/workflow/referenceKeys.ts +++ b/workflow/referenceKeys.ts @@ -4,6 +4,7 @@ export interface ReferenceSource { type?: string; } +// Prefix digit-starting keys so they remain valid template/fork identifiers. const NUMERIC_REFERENCE_PREFIX = "n_"; export function toReferenceKey(value: string | undefined | null, fallback = "value"): string { diff --git a/workflow/runner.ts b/workflow/runner.ts index 2a8f634..a823fbc 100644 --- a/workflow/runner.ts +++ b/workflow/runner.ts @@ -61,8 +61,8 @@ export class WorkflowRunner { const nextContext = this.extendContext(context, block, result); // A block can be reachable via both a direct parent edge and a fork // branch, so collapse duplicates before recursing further. - const nextBlocks = this.dedupeBlocks([...this.findChildBlocks(block), ...this.evaluateFork(block, result, nextContext)]); - const nestedResults = await Promise.all(nextBlocks.map(nextBlock => this.executeBlockTree(nextBlock, depth + 1, nextContext))); + const uniqueNextBlocks = this.dedupeBlocks([...this.findChildBlocks(block), ...this.evaluateFork(block, result, nextContext)]); + const nestedResults = await Promise.all(uniqueNextBlocks.map(nextBlock => this.executeBlockTree(nextBlock, depth + 1, nextContext))); return [result, ...nestedResults.flat()]; } catch (err) { @@ -77,13 +77,15 @@ export class WorkflowRunner { const resultsObject: { [key: string]: any } = {}; for (const result of expressionsResults) { resultsObject[result.name] = result.value; + // Preserve the stored result key while also exposing a normalized + // alias for template/fork references that require identifier-safe keys. const normalizedResultKey = toReferenceKey(result.name, "expression"); resultsObject[normalizedResultKey] = result.value; } // Keep a stable `result` alias for existing forks/samples while letting // richer blocks expose additional named expression outputs. Explicit // `result` expressions take precedence over this implicit fallback. - const lastExpressionResult = expressionsResults[expressionsResults.length - 1]; + const lastExpressionResult = expressionsResults.length > 0 ? expressionsResults[expressionsResults.length - 1] : null; if (lastExpressionResult && lastExpressionResult.name !== "result" && !("result" in resultsObject)) { resultsObject.result = lastExpressionResult.value; } From fbcf08c9eb41417cb3aa2a269954aad6d6825149 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 27 Jun 2026 06:08:18 +0000 Subject: [PATCH 7/7] fix: address final review feedback --- ui/connections.ts | 9 ++++++++- ui/panel.ts | 14 +++++++------- workflow/expression.ts | 4 ++-- workflow/runner.ts | 2 +- 4 files changed, 18 insertions(+), 11 deletions(-) diff --git a/ui/connections.ts b/ui/connections.ts index 0a84e37..829554d 100644 --- a/ui/connections.ts +++ b/ui/connections.ts @@ -8,10 +8,17 @@ const MOBILE_NODE_MAX_WIDTH = 180; const DESKTOP_NODE_WIDTH = 200; let cachedNodeWidth = typeof window !== "undefined" ? calculateNodeWidth(window.innerWidth) : DESKTOP_NODE_WIDTH; +let resizeFrame = 0; if (typeof window !== "undefined") { window.addEventListener("resize", () => { - cachedNodeWidth = calculateNodeWidth(window.innerWidth); + if (resizeFrame) { + cancelAnimationFrame(resizeFrame); + } + resizeFrame = requestAnimationFrame(() => { + cachedNodeWidth = calculateNodeWidth(window.innerWidth); + resizeFrame = 0; + }); }); } diff --git a/ui/panel.ts b/ui/panel.ts index b82dddd..f681b3a 100644 --- a/ui/panel.ts +++ b/ui/panel.ts @@ -8,7 +8,7 @@ import { createBlockReferenceLookup, ensureUniqueReferenceKey, resolveExpression let panelEl: HTMLDivElement; let currentNodeId: string | null = null; -const STANDALONE_RESULT_TOKEN = /(^|[^A-Za-z0-9_])\$result(?=[^A-Za-z0-9_]|$)/g; +const STANDALONE_RESULT_PATTERN = /(? `${prefix}$${change.nextKey}`); + nextValue = nextValue.replace(change.legacyCurrentForkPattern, () => `$${change.nextKey}`); } return nextValue; @@ -920,12 +920,12 @@ function insertBranchReference(statement: any, reference: string): string { if (!current.trim()) { return JSON.stringify(["==", reference, ""]); } - STANDALONE_RESULT_TOKEN.lastIndex = 0; - if (STANDALONE_RESULT_TOKEN.test(current)) { + STANDALONE_RESULT_PATTERN.lastIndex = 0; + if (STANDALONE_RESULT_PATTERN.test(current)) { // Replace only standalone `$result` tokens so placeholders like // `$result_value` or `$results` keep their original meaning. - STANDALONE_RESULT_TOKEN.lastIndex = 0; - return current.replace(STANDALONE_RESULT_TOKEN, (_, prefix: string) => `${prefix}${reference}`); + STANDALONE_RESULT_PATTERN.lastIndex = 0; + return current.replace(STANDALONE_RESULT_PATTERN, () => reference); } return appendReference(current, reference); } diff --git a/workflow/expression.ts b/workflow/expression.ts index 8241b7f..01edd3c 100644 --- a/workflow/expression.ts +++ b/workflow/expression.ts @@ -163,8 +163,8 @@ export class WorkflowExpressionMath extends WorkflowExpression { */ public async compute(context: any = {}): Promise { // FIXME: this.parameters.expression is untyped - const expression = this.contextualize(context, this.parameters.expression); - let result = evaluate(expression); + const contextualizedExpression = this.contextualize(context, this.parameters.expression); + let result = evaluate(contextualizedExpression); if (this.withResult) { result = await this.withResult.compute(result); } diff --git a/workflow/runner.ts b/workflow/runner.ts index a823fbc..8bb0667 100644 --- a/workflow/runner.ts +++ b/workflow/runner.ts @@ -85,7 +85,7 @@ export class WorkflowRunner { // Keep a stable `result` alias for existing forks/samples while letting // richer blocks expose additional named expression outputs. Explicit // `result` expressions take precedence over this implicit fallback. - const lastExpressionResult = expressionsResults.length > 0 ? expressionsResults[expressionsResults.length - 1] : null; + const lastExpressionResult = expressionsResults.at(-1); if (lastExpressionResult && lastExpressionResult.name !== "result" && !("result" in resultsObject)) { resultsObject.result = lastExpressionResult.value; }