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; +}