Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ <h2>Run Console</h2>
type="button"
aria-label="Cancel run"
>
<span class="icon icon-rectangle-2698 icon-small" aria-hidden="true"></span>
<span class="icon icon-theme-light-state-open icon-small" aria-hidden="true"></span>
</button>
</div>
</div>
Expand Down
124 changes: 90 additions & 34 deletions apps/web/src/app/workflow-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,11 @@ const SUBAGENT_HANDLE = 'subagent';
const SUBAGENT_TARGET_HANDLE = 'subagent-target';
const IF_PORT_BASE_TOP = 45;
const IF_PORT_STEP = 30;
const IF_COLLAPSED_MULTI_CONDITION_PORT_TOP = 18;
const IF_COLLAPSED_MULTI_FALLBACK_PORT_TOP = 45;
const SUBAGENT_PORT_MIN_TOP = 42;
const DEFAULT_HEADER_CENTER_Y = 24;
const DEFAULT_SECONDARY_CENTER_Y = 81;
const PORT_RADIUS = 6;
const AGGREGATE_PORT_RADIUS = 8;
const PREVIOUS_OUTPUT_TEMPLATE = '{{PREVIOUS_OUTPUT}}';
const GENERIC_AGENT_SPINNER_KEY = '__generic_agent_spinner__';
const IF_CONDITION_OPERATORS = [
Expand Down Expand Up @@ -650,7 +652,7 @@ export class WorkflowEditor {
}
return {
x: targetNode.x,
y: targetNode.y + 24
y: targetNode.y + this.getNodeHeaderCenterYOffset(targetNode)
};
}

Expand Down Expand Up @@ -852,7 +854,7 @@ export class WorkflowEditor {

getIfConditionPortTop(node: EditorNode, index: number): number {
if (node.data?.collapsed) {
return this.getIfPortTop(index);
return this.getNodeHeaderPortTop(node);
}

const nodeEl = document.getElementById(node.id);
Expand All @@ -872,7 +874,7 @@ export class WorkflowEditor {
getIfFallbackPortTop(node: EditorNode): number {
const conditions = this.getIfConditions(node);
if (node.data?.collapsed) {
return this.getIfPortTop(conditions.length);
return this.getNodeSecondaryPortTop(node);
}

const nodeEl = document.getElementById(node.id);
Expand Down Expand Up @@ -959,11 +961,11 @@ export class WorkflowEditor {
if (node.type === 'if') {
if (this.shouldAggregateCollapsedIfPorts(node)) {
if (sourceHandle === IF_FALLBACK_HANDLE) {
return IF_COLLAPSED_MULTI_FALLBACK_PORT_TOP + 6;
return this.getNodeSecondaryCenterYOffset(node);
}
const conditionIndex = this.getIfConditionIndexFromHandle(sourceHandle);
if (conditionIndex !== null) {
return IF_COLLAPSED_MULTI_CONDITION_PORT_TOP + 6;
return this.getNodeHeaderCenterYOffset(node);
}
}
if (sourceHandle === IF_FALLBACK_HANDLE) {
Expand All @@ -975,9 +977,35 @@ export class WorkflowEditor {
}
}

if (sourceHandle === 'approve') return 51;
if (sourceHandle === 'reject') return 81;
return 24;
if (sourceHandle === 'approve') return this.getNodeHeaderCenterYOffset(node);
if (sourceHandle === 'reject') return this.getNodeSecondaryCenterYOffset(node);
return this.getNodeHeaderCenterYOffset(node);
}

getNodeHeaderCenterYOffset(node: EditorNode): number {
const nodeEl = document.getElementById(node.id);
const headerEl = nodeEl?.querySelector('.node-header');
if (!(headerEl instanceof HTMLElement)) return DEFAULT_HEADER_CENTER_Y;
return Math.round(headerEl.offsetTop + (headerEl.offsetHeight / 2));
}

getNodeHeaderPortTop(node: EditorNode): number {
return this.getNodeHeaderCenterYOffset(node) - PORT_RADIUS;
}

getNodeSecondaryCenterYOffset(node: EditorNode): number {
const nodeEl = document.getElementById(node.id);
const headerEl = nodeEl?.querySelector('.node-header');
if (!(nodeEl instanceof HTMLElement) || !(headerEl instanceof HTMLElement)) {
return DEFAULT_SECONDARY_CENTER_Y;
}
const bodyTop = headerEl.offsetTop + headerEl.offsetHeight;
const bodyHeight = Math.max(nodeEl.offsetHeight - bodyTop, PORT_RADIUS * 2);
return Math.round(bodyTop + (bodyHeight / 2));
}

getNodeSecondaryPortTop(node: EditorNode): number {
return this.getNodeSecondaryCenterYOffset(node) - PORT_RADIUS;
}
Comment on lines +985 to 1009
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Cache node geometry per render pass to avoid layout thrash.

Lines 985–1009 read live layout metrics, and those helpers are hit repeatedly while rendering connections (Line 2570+). On dense graphs this can trigger noticeable drag/reconnect jank due to repeated sync layout reads.

⚡ Suggested direction (cache metrics once per render cycle)
+type NodePortMetrics = { headerCenterY: number; secondaryCenterY: number };
+
+private getNodePortMetrics(node: EditorNode, cache: Map<string, NodePortMetrics>): NodePortMetrics {
+    const cached = cache.get(node.id);
+    if (cached) return cached;
+    const metrics = {
+        headerCenterY: this.getNodeHeaderCenterYOffset(node),
+        secondaryCenterY: this.getNodeSecondaryCenterYOffset(node)
+    };
+    cache.set(node.id, metrics);
+    return metrics;
+}
...
 renderConnections(refreshSelectedForm = true) {
     if (!this.connectionsLayer) return;
+    const nodeMetrics = new Map<string, NodePortMetrics>();
...
     this.connections.forEach((conn: any, index: any) => {
         const sourceNode = this.nodes.find((n: any) => n.id === conn.source);
         const targetNode = this.nodes.find((n: any) => n.id === conn.target);
         if (!sourceNode || !targetNode) return;
+        const sourceMetrics = this.getNodePortMetrics(sourceNode, nodeMetrics);
+        const targetMetrics = this.getNodePortMetrics(targetNode, nodeMetrics);
-        const startPoint = this.getConnectionStartPoint(sourceNode, conn.sourceHandle);
-        const endPoint = this.getConnectionEndPoint(targetNode, conn.sourceHandle);
+        const startPoint = this.getConnectionStartPoint(sourceNode, conn.sourceHandle, sourceMetrics);
+        const endPoint = this.getConnectionEndPoint(targetNode, conn.sourceHandle, targetMetrics);

Also applies to: 2570-2602

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/app/workflow-editor.ts` around lines 985 - 1009, These helpers
(getNodeHeaderCenterYOffset, getNodeHeaderPortTop,
getNodeSecondaryCenterYOffset, getNodeSecondaryPortTop) are causing layout
thrash by repeatedly reading DOM metrics; fix by introducing a per-render-pass
cache (e.g., a Map keyed by node.id) that stores computed geometry
{headerCenterY, secondaryCenterY} and is populated the first time a node is
measured during the current render cycle, then returned for subsequent calls;
ensure the cache is cleared at the start of the connection-render pass (where
connections are drawn) or whenever node layout changes so measurements are
refreshed; update those four methods to read/write the cache instead of querying
the DOM every call.


setWorkflowState(state: WorkflowState): void {
Expand All @@ -1003,6 +1031,15 @@ export class WorkflowEditor {
}
}

setCancelRunButtonHint(reason: string | null): void {
if (!this.cancelRunButton) return;
if (reason) {
this.cancelRunButton.setAttribute('data-tooltip', reason);
} else {
this.cancelRunButton.removeAttribute('data-tooltip');
}
}

setCanvasValidationMessage(message: string | null): void {
if (this.canvasValidationTimeout !== null) {
clearTimeout(this.canvasValidationTimeout);
Expand Down Expand Up @@ -1138,6 +1175,7 @@ export class WorkflowEditor {
const showCancel = this.workflowState === 'running';
this.cancelRunButton.style.display = showCancel ? 'inline-flex' : 'none';
this.cancelRunButton.disabled = !showCancel;
this.setCancelRunButtonHint(showCancel ? 'Cancel workflow' : null);
}

if (this.clearButton) {
Expand Down Expand Up @@ -2209,14 +2247,14 @@ export class WorkflowEditor {

} else if (node.type === 'approval') {
container.appendChild(buildLabel('Approval Message'));
const pInput = document.createElement('input');
pInput.type = 'text';
pInput.className = 'input';
const pInput = document.createElement('textarea');
pInput.className = 'input textarea-input';
pInput.rows = 4;
pInput.value = data.prompt || '';
pInput.placeholder = 'Message shown to user when approval is required';
pInput.addEventListener('input', (e: any) => {
data.prompt = e.target.value;
this.scheduleSave();
this.updatePreview(node);
});
container.appendChild(pInput);

Expand Down Expand Up @@ -2269,52 +2307,49 @@ export class WorkflowEditor {
node.id,
SUBAGENT_TARGET_HANDLE,
'port-subagent-target',
'Subagent target'
'Set as subagent'
)
);
}

if (node.type !== 'start') {
const portIn = this.createPort(node.id, 'input', 'port-in');
const inputTooltip = node.type === 'end' ? 'End input' : 'Input';
const portIn = this.createPort(node.id, 'input', 'port-in', inputTooltip, this.getNodeHeaderPortTop(node));
el.appendChild(portIn);
}

if (node.type !== 'end') {
if (node.type === 'if') {
const conditions = this.getIfConditions(node);
if (this.shouldAggregateCollapsedIfPorts(node)) {
const title = `${conditions.length} condition branches (expand to wire specific branches)`;
const aggregateConditionPort = this.createPort(
node.id,
this.getIfConditionHandle(0),
'port-out port-condition port-condition-aggregate',
title,
IF_COLLAPSED_MULTI_CONDITION_PORT_TOP,
'Expand to connect',
this.getNodeHeaderCenterYOffset(node) - AGGREGATE_PORT_RADIUS,
false
);
aggregateConditionPort.textContent = String(conditions.length);
aggregateConditionPort.setAttribute('aria-label', `${conditions.length} conditions`);
aggregateConditionPort.setAttribute('aria-label', 'Expand to connect');
el.appendChild(aggregateConditionPort);
el.appendChild(
this.createPort(
node.id,
IF_FALLBACK_HANDLE,
'port-out port-condition-fallback',
'False fallback',
IF_COLLAPSED_MULTI_FALLBACK_PORT_TOP
'Fallback path',
this.getNodeSecondaryPortTop(node)
)
);
} else {
conditions.forEach((condition: any, index: any) => {
const operatorLabel = condition.operator === 'contains' ? 'Contains' : 'Equal';
const conditionValue = condition.value || '';
const title = `Condition ${index + 1}: ${operatorLabel} "${conditionValue}"`;
conditions.forEach((_condition: any, index: any) => {
el.appendChild(
this.createPort(
node.id,
this.getIfConditionHandle(index),
'port-out port-condition',
title,
`Condition ${index + 1}`,
this.getIfConditionPortTop(node, index)
)
);
Expand All @@ -2324,28 +2359,45 @@ export class WorkflowEditor {
node.id,
IF_FALLBACK_HANDLE,
'port-out port-condition-fallback',
'False fallback',
'Fallback path',
this.getIfFallbackPortTop(node)
)
);
}
} else if (node.type === 'agent') {
el.appendChild(this.createPort(node.id, 'output', 'port-out'));
el.appendChild(this.createPort(node.id, 'output', 'port-out', 'Output', this.getNodeHeaderPortTop(node)));
if (node.data?.tools?.subagents) {
el.appendChild(
this.createPort(
node.id,
SUBAGENT_HANDLE,
'port-subagent',
'Subagent'
'Add subagent'
)
);
}
} else if (node.type === 'approval') {
el.appendChild(this.createPort(node.id, 'approve', 'port-out port-true', 'Approve'));
el.appendChild(this.createPort(node.id, 'reject', 'port-out port-false', 'Reject'));
el.appendChild(
this.createPort(
node.id,
'approve',
'port-out port-true',
'Approve path',
this.getNodeHeaderPortTop(node)
)
);
el.appendChild(
this.createPort(
node.id,
'reject',
'port-out port-false',
'Reject path',
this.getNodeSecondaryPortTop(node)
)
);
} else {
el.appendChild(this.createPort(node.id, 'output', 'port-out'));
const outputTooltip = node.type === 'start' ? 'Next step' : 'Output';
el.appendChild(this.createPort(node.id, 'output', 'port-out', outputTooltip, this.getNodeHeaderPortTop(node)));
}
}
}
Expand All @@ -2360,7 +2412,11 @@ export class WorkflowEditor {
): HTMLDivElement {
const port = document.createElement('div');
port.className = `port ${className}${connectable ? '' : ' port-disabled'}`;
if (title) port.title = title;
if (title) {
port.title = title;
port.setAttribute('data-tooltip', title);
port.setAttribute('aria-label', title);
}
if (typeof top === 'number') {
port.style.top = `${top}px`;
}
Expand Down
Loading