From ddb6f3e11dc974368db7cc3769c0663dbc0e559f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Galv=C3=A3o?= Date: Thu, 26 Feb 2026 15:17:04 -0300 Subject: [PATCH 1/4] feat(web): polish cancel workflow button tooltip and icon --- apps/web/index.html | 2 +- apps/web/src/app/workflow-editor.ts | 10 +++++ apps/web/src/workflow-editor.css | 57 +++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 1 deletion(-) 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..a06ecd2 100644 --- a/apps/web/src/app/workflow-editor.ts +++ b/apps/web/src/app/workflow-editor.ts @@ -1003,6 +1003,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 +1147,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) { diff --git a/apps/web/src/workflow-editor.css b/apps/web/src/workflow-editor.css index b77f421..c44649c 100644 --- a/apps/web/src/workflow-editor.css +++ b/apps/web/src/workflow-editor.css @@ -1354,6 +1354,8 @@ path.connection-line.active { display: none; align-items: center; justify-content: center; + position: relative; + overflow: visible; } .run-button:disabled, @@ -1382,6 +1384,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 +1398,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 +1449,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; +} From 9214f6b3c64c9c3d07e32232db14e1f9bd75c2c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Galv=C3=A3o?= Date: Thu, 26 Feb 2026 15:24:49 -0300 Subject: [PATCH 2/4] feat(web): align node connectors with header sections --- apps/web/src/app/workflow-editor.ts | 78 ++++++++++++++++++++++------- 1 file changed, 61 insertions(+), 17 deletions(-) diff --git a/apps/web/src/app/workflow-editor.ts b/apps/web/src/app/workflow-editor.ts index a06ecd2..2234fd3 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 { @@ -2285,7 +2313,7 @@ export class WorkflowEditor { } if (node.type !== 'start') { - const portIn = this.createPort(node.id, 'input', 'port-in'); + const portIn = this.createPort(node.id, 'input', 'port-in', '', this.getNodeHeaderPortTop(node)); el.appendChild(portIn); } @@ -2299,7 +2327,7 @@ export class WorkflowEditor { this.getIfConditionHandle(0), 'port-out port-condition port-condition-aggregate', title, - IF_COLLAPSED_MULTI_CONDITION_PORT_TOP, + this.getNodeHeaderCenterYOffset(node) - AGGREGATE_PORT_RADIUS, false ); aggregateConditionPort.textContent = String(conditions.length); @@ -2311,7 +2339,7 @@ export class WorkflowEditor { IF_FALLBACK_HANDLE, 'port-out port-condition-fallback', 'False fallback', - IF_COLLAPSED_MULTI_FALLBACK_PORT_TOP + this.getNodeSecondaryPortTop(node) ) ); } else { @@ -2340,7 +2368,7 @@ export class WorkflowEditor { ); } } else if (node.type === 'agent') { - el.appendChild(this.createPort(node.id, 'output', 'port-out')); + el.appendChild(this.createPort(node.id, 'output', 'port-out', '', this.getNodeHeaderPortTop(node))); if (node.data?.tools?.subagents) { el.appendChild( this.createPort( @@ -2352,10 +2380,26 @@ export class WorkflowEditor { ); } } 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', + this.getNodeHeaderPortTop(node) + ) + ); + el.appendChild( + this.createPort( + node.id, + 'reject', + 'port-out port-false', + 'Reject', + this.getNodeSecondaryPortTop(node) + ) + ); } else { - el.appendChild(this.createPort(node.id, 'output', 'port-out')); + el.appendChild(this.createPort(node.id, 'output', 'port-out', '', this.getNodeHeaderPortTop(node))); } } } From c62ea003d3de59df482f9bb9b50c5b794cce76e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Galv=C3=A3o?= Date: Thu, 26 Feb 2026 15:28:34 -0300 Subject: [PATCH 3/4] fix(web): live-update approval preview and use textarea --- apps/web/src/app/workflow-editor.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/src/app/workflow-editor.ts b/apps/web/src/app/workflow-editor.ts index 2234fd3..a52f42d 100644 --- a/apps/web/src/app/workflow-editor.ts +++ b/apps/web/src/app/workflow-editor.ts @@ -2247,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); From 50bf8b7648afc9e1ebdf6a3dfc057d986b3b384a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Galv=C3=A3o?= Date: Thu, 26 Feb 2026 15:42:01 -0300 Subject: [PATCH 4/4] feat(web): add connector tooltips and adjust port direction --- apps/web/src/app/workflow-editor.ts | 38 ++++++------ apps/web/src/workflow-editor.css | 95 +++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 18 deletions(-) diff --git a/apps/web/src/app/workflow-editor.ts b/apps/web/src/app/workflow-editor.ts index a52f42d..a639af7 100644 --- a/apps/web/src/app/workflow-editor.ts +++ b/apps/web/src/app/workflow-editor.ts @@ -2307,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', '', this.getNodeHeaderPortTop(node)); + const inputTooltip = node.type === 'end' ? 'End input' : 'Input'; + const portIn = this.createPort(node.id, 'input', 'port-in', inputTooltip, this.getNodeHeaderPortTop(node)); el.appendChild(portIn); } @@ -2321,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, + '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', + '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) ) ); @@ -2362,20 +2359,20 @@ 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', '', this.getNodeHeaderPortTop(node))); + 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' ) ); } @@ -2385,7 +2382,7 @@ export class WorkflowEditor { node.id, 'approve', 'port-out port-true', - 'Approve', + 'Approve path', this.getNodeHeaderPortTop(node) ) ); @@ -2394,12 +2391,13 @@ export class WorkflowEditor { node.id, 'reject', 'port-out port-false', - 'Reject', + 'Reject path', this.getNodeSecondaryPortTop(node) ) ); } else { - el.appendChild(this.createPort(node.id, 'output', 'port-out', '', this.getNodeHeaderPortTop(node))); + const outputTooltip = node.type === 'start' ? 'Next step' : 'Output'; + el.appendChild(this.createPort(node.id, 'output', 'port-out', outputTooltip, this.getNodeHeaderPortTop(node))); } } } @@ -2414,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 c44649c..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); }