diff --git a/apps/web/index.html b/apps/web/index.html
index ccd9d3c..6aba8e9 100644
--- a/apps/web/index.html
+++ b/apps/web/index.html
@@ -89,7 +89,7 @@
Run Console
type="button"
aria-label="Cancel run"
>
-
+
diff --git a/apps/web/src/app/workflow-editor.ts b/apps/web/src/app/workflow-editor.ts
index cdf9bc9..a639af7 100644
--- a/apps/web/src/app/workflow-editor.ts
+++ b/apps/web/src/app/workflow-editor.ts
@@ -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 = [
@@ -650,7 +652,7 @@ export class WorkflowEditor {
}
return {
x: targetNode.x,
- y: targetNode.y + 24
+ y: targetNode.y + this.getNodeHeaderCenterYOffset(targetNode)
};
}
@@ -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);
@@ -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);
@@ -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) {
@@ -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;
}
setWorkflowState(state: WorkflowState): void {
@@ -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);
@@ -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) {
@@ -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);
@@ -2269,13 +2307,14 @@ 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);
}
@@ -2283,38 +2322,34 @@ export class WorkflowEditor {
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)
)
);
@@ -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)));
}
}
}
@@ -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`;
}
diff --git a/apps/web/src/workflow-editor.css b/apps/web/src/workflow-editor.css
index b77f421..db1f506 100644
--- a/apps/web/src/workflow-editor.css
+++ b/apps/web/src/workflow-editor.css
@@ -530,6 +530,101 @@ path.connection-line.active {
transform: none;
}
+.port[data-tooltip]:hover::after,
+.port[data-tooltip]:focus-visible::after {
+ content: attr(data-tooltip);
+ position: absolute;
+ top: 50%;
+ left: auto;
+ right: calc(100% + var(--UI-Spacing-spacing-s));
+ transform: translateY(-50%);
+ width: max-content;
+ max-width: 220px;
+ padding: var(--UI-Spacing-spacing-xs) var(--UI-Spacing-spacing-s);
+ border-radius: var(--UI-Radius-radius-xs);
+ border: 1px solid var(--Colors-Stroke-Strong);
+ background: var(--Colors-Backgrounds-Main-Top);
+ color: var(--Colors-Text-Body-Strong);
+ font-size: var(--Fonts-Body-Default-xxs);
+ line-height: 1.3;
+ white-space: nowrap;
+ z-index: 120;
+ box-shadow: 0 3px 2px 0 var(--Colors-Shadow-Card);
+ pointer-events: none;
+}
+
+.port[data-tooltip]:hover::before,
+.port[data-tooltip]:focus-visible::before {
+ content: '';
+ position: absolute;
+ top: 50%;
+ left: auto;
+ right: calc(100% + var(--UI-Spacing-spacing-xxs));
+ transform: translateY(-50%);
+ border-top: 6px solid transparent;
+ border-bottom: 6px solid transparent;
+ border-right: none;
+ border-left: 6px solid var(--Colors-Backgrounds-Main-Top);
+ z-index: 121;
+ pointer-events: none;
+}
+
+.port-out[data-tooltip]:hover::after,
+.port-out[data-tooltip]:focus-visible::after {
+ left: calc(100% + var(--UI-Spacing-spacing-s));
+ right: auto;
+}
+
+.port-out[data-tooltip]:hover::before,
+.port-out[data-tooltip]:focus-visible::before {
+ left: calc(100% + var(--UI-Spacing-spacing-xxs));
+ right: auto;
+ border-right: 6px solid var(--Colors-Backgrounds-Main-Top);
+ border-left: none;
+}
+
+.port-subagent-target[data-tooltip]:hover::after,
+.port-subagent-target[data-tooltip]:focus-visible::after {
+ top: auto;
+ left: 50%;
+ right: auto;
+ bottom: calc(100% + var(--UI-Spacing-spacing-s));
+ transform: translateX(-50%);
+}
+
+.port-subagent-target[data-tooltip]:hover::before,
+.port-subagent-target[data-tooltip]:focus-visible::before {
+ top: auto;
+ left: 50%;
+ right: auto;
+ bottom: calc(100% + var(--UI-Spacing-spacing-xxs));
+ transform: translateX(-50%);
+ border-top: none;
+ border-bottom: 6px solid var(--Colors-Backgrounds-Main-Top);
+ border-left: 6px solid transparent;
+ border-right: 6px solid transparent;
+}
+
+.port-subagent[data-tooltip]:hover::after,
+.port-subagent[data-tooltip]:focus-visible::after {
+ top: calc(100% + var(--UI-Spacing-spacing-s));
+ left: 50%;
+ right: auto;
+ transform: translateX(-50%);
+}
+
+.port-subagent[data-tooltip]:hover::before,
+.port-subagent[data-tooltip]:focus-visible::before {
+ top: calc(100% + var(--UI-Spacing-spacing-xxs));
+ left: 50%;
+ right: auto;
+ transform: translateX(-50%);
+ border-top: 6px solid var(--Colors-Backgrounds-Main-Top);
+ border-bottom: none;
+ border-left: 6px solid transparent;
+ border-right: 6px solid transparent;
+}
+
.port-in { left: -8px; }
.port-out { right: -8px; }
.port-true { top: 45px; border-color: var(--Colors-Alert-Success-Default); }
@@ -1354,6 +1449,8 @@ path.connection-line.active {
display: none;
align-items: center;
justify-content: center;
+ position: relative;
+ overflow: visible;
}
.run-button:disabled,
@@ -1382,6 +1479,7 @@ path.connection-line.active {
text-align: left;
z-index: 30;
box-shadow: 0 3px 2px 0 var(--Colors-Shadow-Card);
+ pointer-events: none;
}
.run-button:disabled[data-disabled-hint]:hover::before,
@@ -1395,6 +1493,44 @@ path.connection-line.active {
border-right: 6px solid transparent;
border-top: 6px solid var(--Colors-Backgrounds-Main-Top);
z-index: 31;
+ pointer-events: none;
+}
+
+.cancel-run-button[data-tooltip]:hover::after,
+.cancel-run-button[data-tooltip]:focus-visible::after {
+ content: attr(data-tooltip);
+ position: absolute;
+ left: 50%;
+ bottom: calc(100% + var(--UI-Spacing-spacing-s));
+ transform: translateX(-50%);
+ width: max-content;
+ max-width: min(320px, calc(100vw - 32px));
+ padding: var(--UI-Spacing-spacing-s) var(--UI-Spacing-spacing-ms);
+ border-radius: var(--UI-Radius-radius-xs);
+ border: 1px solid var(--Colors-Stroke-Strong);
+ background: var(--Colors-Backgrounds-Main-Top);
+ color: var(--Colors-Text-Body-Strong);
+ font-size: var(--Fonts-Body-Default-xxs);
+ line-height: 1.4;
+ white-space: nowrap;
+ text-align: left;
+ z-index: 30;
+ box-shadow: 0 3px 2px 0 var(--Colors-Shadow-Card);
+ pointer-events: none;
+}
+
+.cancel-run-button[data-tooltip]:hover::before,
+.cancel-run-button[data-tooltip]:focus-visible::before {
+ content: '';
+ position: absolute;
+ left: 50%;
+ bottom: calc(100% + var(--UI-Spacing-spacing-xxs));
+ transform: translateX(-50%);
+ border-left: 6px solid transparent;
+ border-right: 6px solid transparent;
+ border-top: 6px solid var(--Colors-Backgrounds-Main-Top);
+ z-index: 31;
+ pointer-events: none;
}
/* Canvas clear sits at the lower-left edge; avoid clipping the hint bubble */
@@ -1408,3 +1544,19 @@ path.connection-line.active {
left: 20px;
transform: none;
}
+
+/* Cancel button sits at the lower-right edge; avoid clipping the tooltip bubble */
+.cancel-run-button[data-tooltip]:hover::after,
+.cancel-run-button[data-tooltip]:focus-visible::after {
+ left: auto;
+ right: 0;
+ transform: none;
+ max-width: min(320px, calc(100vw - 48px));
+}
+
+.cancel-run-button[data-tooltip]:hover::before,
+.cancel-run-button[data-tooltip]:focus-visible::before {
+ left: auto;
+ right: 20px;
+ transform: none;
+}