From e2d0d615533fa39e85cf2d4a74cec4696e391374 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Galv=C3=A3o?= Date: Wed, 25 Feb 2026 14:11:13 -0300 Subject: [PATCH 01/11] feat(web): aggregate collapsed condition ports --- apps/web/src/app/workflow-editor.ts | 83 ++++++++++++++++++++++------- apps/web/src/workflow-editor.css | 14 +++++ 2 files changed, 79 insertions(+), 18 deletions(-) diff --git a/apps/web/src/app/workflow-editor.ts b/apps/web/src/app/workflow-editor.ts index bfe9e8f..ab1e4df 100644 --- a/apps/web/src/app/workflow-editor.ts +++ b/apps/web/src/app/workflow-editor.ts @@ -20,6 +20,8 @@ const IF_CONDITION_HANDLE_PREFIX = 'condition-'; const IF_FALLBACK_HANDLE = 'false'; 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 IF_CONDITION_OPERATORS = [ { value: 'equal', label: 'Equal' }, { value: 'contains', label: 'Contains' } @@ -470,6 +472,17 @@ export class WorkflowEditor { return IF_PORT_BASE_TOP + (index * IF_PORT_STEP); } + shouldAggregateCollapsedIfPorts(node: EditorNode): boolean { + return node.type === 'if' && Boolean(node.data?.collapsed) && this.getIfConditions(node).length > 1; + } + + refreshNodePorts(node: EditorNode): void { + const el = document.getElementById(node.id); + if (!el) return; + el.querySelectorAll('.port').forEach((port) => port.remove()); + this.renderPorts(node, el); + } + getIfConditions(node: EditorNode): IfCondition[] { if (!node.data) node.data = {}; if (!Array.isArray(node.data.conditions) || node.data.conditions.length === 0) { @@ -514,6 +527,15 @@ export class WorkflowEditor { getOutputPortCenterYOffset(node: EditorNode, sourceHandle?: string): number { if (node.type === 'if') { + if (this.shouldAggregateCollapsedIfPorts(node)) { + if (sourceHandle === IF_FALLBACK_HANDLE) { + return IF_COLLAPSED_MULTI_FALLBACK_PORT_TOP + 6; + } + const conditionIndex = this.getIfConditionIndexFromHandle(sourceHandle); + if (conditionIndex !== null) { + return IF_COLLAPSED_MULTI_CONDITION_PORT_TOP + 6; + } + } if (sourceHandle === IF_FALLBACK_HANDLE) { return this.getIfPortTop(this.getIfConditions(node).length) + 6; } @@ -1202,6 +1224,7 @@ export class WorkflowEditor { e.stopPropagation(); node.data.collapsed = !node.data.collapsed; updateCollapseIcon(); + this.refreshNodePorts(node); this.renderConnections(); }); controls.appendChild(collapseBtn); @@ -1249,6 +1272,7 @@ export class WorkflowEditor { e.stopPropagation(); node.data.collapsed = !node.data.collapsed; updateCollapseIcon(); + this.refreshNodePorts(node); this.renderConnections(); }); @@ -1584,29 +1608,52 @@ export class WorkflowEditor { if (node.type !== 'end') { if (node.type === 'if') { const conditions = this.getIfConditions(node); - conditions.forEach((condition: any, index: any) => { - const operatorLabel = condition.operator === 'contains' ? 'Contains' : 'Equal'; - const conditionValue = condition.value || ''; - const title = `Condition ${index + 1}: ${operatorLabel} "${conditionValue}"`; + 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 + ); + aggregateConditionPort.textContent = String(conditions.length); + aggregateConditionPort.setAttribute('aria-label', `${conditions.length} conditions`); + el.appendChild(aggregateConditionPort); el.appendChild( this.createPort( node.id, - this.getIfConditionHandle(index), - 'port-out port-condition', - title, - this.getIfPortTop(index) + IF_FALLBACK_HANDLE, + 'port-out port-condition-fallback', + 'False fallback', + IF_COLLAPSED_MULTI_FALLBACK_PORT_TOP ) ); - }); - el.appendChild( - this.createPort( - node.id, - IF_FALLBACK_HANDLE, - 'port-out port-condition-fallback', - 'False fallback', - this.getIfPortTop(conditions.length) - ) - ); + } 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}"`; + el.appendChild( + this.createPort( + node.id, + this.getIfConditionHandle(index), + 'port-out port-condition', + title, + this.getIfPortTop(index) + ) + ); + }); + el.appendChild( + this.createPort( + node.id, + IF_FALLBACK_HANDLE, + 'port-out port-condition-fallback', + 'False fallback', + this.getIfPortTop(conditions.length) + ) + ); + } } 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')); diff --git a/apps/web/src/workflow-editor.css b/apps/web/src/workflow-editor.css index 835c85d..942b812 100644 --- a/apps/web/src/workflow-editor.css +++ b/apps/web/src/workflow-editor.css @@ -436,6 +436,20 @@ path.connection-line.active { .port-true { top: 45px; border-color: var(--Colors-Alert-Success-Default); } .port-false { top: 75px; border-color: var(--Colors-Alert-Error-Default); } .port-condition { border-color: var(--Colors-Primary-Medium); } +.port-condition-aggregate { + width: 12px; + height: 12px; + border-radius: 50%; + right: -8px; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + font-size: 8px; + font-weight: 700; + color: var(--Colors-Primary-Medium); + line-height: 1; +} .port-condition-fallback { border-color: var(--Colors-Alert-Error-Default); } /* Form Styles inside Node */ From 4c32002caa043bb8baa4b3be19f8fd32e393a4ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Galv=C3=A3o?= Date: Wed, 25 Feb 2026 14:13:55 -0300 Subject: [PATCH 02/11] fix(web): align condition connectors with rows --- apps/web/src/app/workflow-editor.ts | 52 ++++++++++++++++++++++++++--- 1 file changed, 48 insertions(+), 4 deletions(-) diff --git a/apps/web/src/app/workflow-editor.ts b/apps/web/src/app/workflow-editor.ts index ab1e4df..f36b141 100644 --- a/apps/web/src/app/workflow-editor.ts +++ b/apps/web/src/app/workflow-editor.ts @@ -472,6 +472,50 @@ export class WorkflowEditor { return IF_PORT_BASE_TOP + (index * IF_PORT_STEP); } + getIfConditionPortTop(node: EditorNode, index: number): number { + if (node.data?.collapsed) { + return this.getIfPortTop(index); + } + + const nodeEl = document.getElementById(node.id); + if (!nodeEl) { + return this.getIfPortTop(index); + } + + const conditionRows = Array.from(nodeEl.querySelectorAll('.condition-row')) as HTMLElement[]; + const row = conditionRows[index]; + if (!row) { + return this.getIfPortTop(index); + } + + return Math.round(row.offsetTop + (row.offsetHeight / 2) - 6); + } + + getIfFallbackPortTop(node: EditorNode): number { + const conditions = this.getIfConditions(node); + if (node.data?.collapsed) { + return this.getIfPortTop(conditions.length); + } + + const nodeEl = document.getElementById(node.id); + if (!nodeEl) { + return this.getIfPortTop(conditions.length); + } + + const conditionRows = Array.from(nodeEl.querySelectorAll('.condition-row')) as HTMLElement[]; + if (conditionRows.length === 0) { + return this.getIfPortTop(conditions.length); + } + + const lastRow = conditionRows[conditionRows.length - 1]; + const lastCenterTop = lastRow.offsetTop + (lastRow.offsetHeight / 2) - 6; + const dynamicStep = conditionRows.length > 1 + ? conditionRows[conditionRows.length - 1].offsetTop - conditionRows[conditionRows.length - 2].offsetTop + : IF_PORT_STEP; + + return Math.round(lastCenterTop + dynamicStep); + } + shouldAggregateCollapsedIfPorts(node: EditorNode): boolean { return node.type === 'if' && Boolean(node.data?.collapsed) && this.getIfConditions(node).length > 1; } @@ -537,11 +581,11 @@ export class WorkflowEditor { } } if (sourceHandle === IF_FALLBACK_HANDLE) { - return this.getIfPortTop(this.getIfConditions(node).length) + 6; + return this.getIfFallbackPortTop(node) + 6; } const conditionIndex = this.getIfConditionIndexFromHandle(sourceHandle); if (conditionIndex !== null) { - return this.getIfPortTop(conditionIndex) + 6; + return this.getIfConditionPortTop(node, conditionIndex) + 6; } } @@ -1640,7 +1684,7 @@ export class WorkflowEditor { this.getIfConditionHandle(index), 'port-out port-condition', title, - this.getIfPortTop(index) + this.getIfConditionPortTop(node, index) ) ); }); @@ -1650,7 +1694,7 @@ export class WorkflowEditor { IF_FALLBACK_HANDLE, 'port-out port-condition-fallback', 'False fallback', - this.getIfPortTop(conditions.length) + this.getIfFallbackPortTop(node) ) ); } From 997aee9b404174284763d94b94439a574070bb95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Galv=C3=A3o?= Date: Wed, 25 Feb 2026 14:16:09 -0300 Subject: [PATCH 03/11] style(web): increase collapsed condition counter size --- apps/web/src/workflow-editor.css | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/src/workflow-editor.css b/apps/web/src/workflow-editor.css index 942b812..20cbe10 100644 --- a/apps/web/src/workflow-editor.css +++ b/apps/web/src/workflow-editor.css @@ -437,15 +437,15 @@ path.connection-line.active { .port-false { top: 75px; border-color: var(--Colors-Alert-Error-Default); } .port-condition { border-color: var(--Colors-Primary-Medium); } .port-condition-aggregate { - width: 12px; - height: 12px; + width: 16px; + height: 16px; border-radius: 50%; - right: -8px; + right: -10px; display: flex; align-items: center; justify-content: center; padding: 0; - font-size: 8px; + font-size: 10px; font-weight: 700; color: var(--Colors-Primary-Medium); line-height: 1; From 1497502d76337e425d8e779e37aeb508003bbbf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Galv=C3=A3o?= Date: Wed, 25 Feb 2026 14:25:37 -0300 Subject: [PATCH 04/11] fix(web): keep condition ports aligned and lock clear while running --- apps/web/src/app/workflow-editor.ts | 68 +++++++++++++++++++---------- apps/web/src/workflow-editor.css | 21 +++++++-- 2 files changed, 62 insertions(+), 27 deletions(-) diff --git a/apps/web/src/app/workflow-editor.ts b/apps/web/src/app/workflow-editor.ts index f36b141..5da4906 100644 --- a/apps/web/src/app/workflow-editor.ts +++ b/apps/web/src/app/workflow-editor.ts @@ -188,6 +188,8 @@ export class WorkflowEditor { private cancelRunButton: HTMLButtonElement | null; + private clearButton: HTMLButtonElement | null; + private zoomValue: HTMLElement | null; private workflowState: WorkflowState; @@ -266,6 +268,7 @@ export class WorkflowEditor { this.initialPrompt = document.getElementById('initial-prompt') as HTMLInputElement | HTMLTextAreaElement | null; this.runButton = document.getElementById('btn-run') as HTMLButtonElement | null; this.cancelRunButton = document.getElementById('btn-cancel-run') as HTMLButtonElement | null; + this.clearButton = document.getElementById('btn-clear') as HTMLButtonElement | null; this.zoomValue = document.getElementById('zoom-value'); this.workflowState = 'idle'; this.rightPanel = document.getElementById('right-panel'); @@ -608,6 +611,15 @@ export class WorkflowEditor { } } + setClearButtonHint(reason: string | null): void { + if (!this.clearButton) return; + if (reason) { + this.clearButton.setAttribute('data-disabled-hint', reason); + } else { + this.clearButton.removeAttribute('data-disabled-hint'); + } + } + isAbortError(error: unknown): boolean { if (!error) return false; if (error instanceof Error && error.name === 'AbortError') return true; @@ -706,6 +718,14 @@ export class WorkflowEditor { this.cancelRunButton.style.display = showCancel ? 'inline-flex' : 'none'; this.cancelRunButton.disabled = !showCancel; } + + if (this.clearButton) { + const clearDisabledReason = this.workflowState === 'running' + ? 'Cannot clear canvas while workflow is running.' + : null; + this.clearButton.disabled = Boolean(clearDisabledReason); + this.setClearButtonHint(clearDisabledReason); + } switch (this.workflowState) { case 'running': @@ -900,28 +920,28 @@ export class WorkflowEditor { if (cancelRunBtn) { cancelRunBtn.addEventListener('click', () => this.cancelRunningWorkflow()); } - const clearBtn = document.getElementById('btn-clear'); - if (clearBtn) { - clearBtn.addEventListener('click', async () => { - const confirmed = await this.openConfirmModal({ - title: 'Clear Canvas', - message: 'Remove all nodes and connections from the canvas?', - confirmLabel: 'Clear', - cancelLabel: 'Keep' + if (this.clearButton) { + this.clearButton.addEventListener('click', async () => { + if (this.workflowState === 'running') return; + const confirmed = await this.openConfirmModal({ + title: 'Clear Canvas', + message: 'Remove all nodes and connections from the canvas?', + confirmLabel: 'Clear', + cancelLabel: 'Keep' + }); + if(!confirmed) return; + this.nodes = []; + this.connections = []; + this.render(); + this.addDefaultStartNode(); + this.currentPrompt = ''; + this.currentRunId = null; + this.clearRunId(); + if (this.chatMessages) { + this.chatMessages.innerHTML = '
Canvas cleared. Start building your next workflow.
'; + } + this.setWorkflowState('idle'); }); - if(!confirmed) return; - this.nodes = []; - this.connections = []; - this.render(); - this.addDefaultStartNode(); - this.currentPrompt = ''; - this.currentRunId = null; - this.clearRunId(); - if (this.chatMessages) { - this.chatMessages.innerHTML = '
Canvas cleared. Start building your next workflow.
'; - } - this.setWorkflowState('idle'); - }); } const zoomInBtn = document.getElementById('btn-zoom-in'); @@ -1334,12 +1354,12 @@ export class WorkflowEditor { this.renderNodeForm(node, body); el.appendChild(body); - // Ports - this.renderPorts(node, el); - if (this.nodesLayer) { this.nodesLayer.appendChild(el); } + + // Render ports after mount so row-based offsets can be measured correctly. + this.renderPorts(node, el); } updateNodeHeader(node: any) { diff --git a/apps/web/src/workflow-editor.css b/apps/web/src/workflow-editor.css index 20cbe10..9730154 100644 --- a/apps/web/src/workflow-editor.css +++ b/apps/web/src/workflow-editor.css @@ -976,12 +976,14 @@ path.connection-line.active { justify-content: center; } -.run-button:disabled { +.run-button:disabled, +.canvas-clear:disabled { pointer-events: auto; cursor: not-allowed; } -.run-button:disabled[data-disabled-hint]:hover::after { +.run-button:disabled[data-disabled-hint]:hover::after, +.canvas-clear:disabled[data-disabled-hint]:hover::after { content: attr(data-disabled-hint); position: absolute; left: 50%; @@ -1002,7 +1004,8 @@ path.connection-line.active { box-shadow: 0 3px 2px 0 var(--Colors-Shadow-Card); } -.run-button:disabled[data-disabled-hint]:hover::before { +.run-button:disabled[data-disabled-hint]:hover::before, +.canvas-clear:disabled[data-disabled-hint]:hover::before { content: ''; position: absolute; left: 50%; @@ -1013,3 +1016,15 @@ path.connection-line.active { border-top: 6px solid var(--Colors-Backgrounds-Main-Top); z-index: 31; } + +/* Canvas clear sits at the lower-left edge; avoid clipping the hint bubble */ +.canvas-clear:disabled[data-disabled-hint]:hover::after { + left: 0; + transform: none; + max-width: min(320px, calc(100vw - 48px)); +} + +.canvas-clear:disabled[data-disabled-hint]:hover::before { + left: 20px; + transform: none; +} From 77beee72fe727404c2d567416b23ea91e4f36977 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Galv=C3=A3o?= Date: Wed, 25 Feb 2026 14:35:38 -0300 Subject: [PATCH 05/11] fix(web): polish approval chat flow and message ordering --- apps/web/src/app/workflow-editor.ts | 113 +++++++++++++++++++++++----- apps/web/src/workflow-editor.css | 79 +++++++++++-------- 2 files changed, 140 insertions(+), 52 deletions(-) diff --git a/apps/web/src/app/workflow-editor.ts b/apps/web/src/app/workflow-editor.ts index 5da4906..3b880e1 100644 --- a/apps/web/src/app/workflow-editor.ts +++ b/apps/web/src/app/workflow-editor.ts @@ -1891,6 +1891,11 @@ export class WorkflowEditor { if (!this.pendingApprovalRequest?.container) return; const container = this.pendingApprovalRequest.container; + this.renderApprovalResultCard(container, decision, note); + this.pendingApprovalRequest = null; + } + + renderApprovalResultCard(container: HTMLElement, decision: 'approve' | 'reject', note: string = '') { container.className = 'chat-message approval-result'; container.classList.add(decision === 'approve' ? 'approved' : 'rejected'); @@ -1899,19 +1904,29 @@ export class WorkflowEditor { const text = decision === 'approve' ? 'Approved' : 'Rejected'; container.innerHTML = ''; + + const labelEl = document.createElement('span'); + labelEl.className = 'chat-message-label'; + labelEl.textContent = 'Approval decision'; + container.appendChild(labelEl); const content = document.createElement('div'); content.className = 'approval-result-content'; + + const status = document.createElement('div'); + status.className = 'approval-result-status'; const iconEl = document.createElement('span'); iconEl.className = 'approval-result-icon'; iconEl.textContent = icon; - content.appendChild(iconEl); + status.appendChild(iconEl); const textEl = document.createElement('span'); textEl.className = 'approval-result-text'; textEl.textContent = text; - content.appendChild(textEl); + status.appendChild(textEl); + + content.appendChild(status); if (trimmedNote) { const noteEl = document.createElement('div'); @@ -1921,7 +1936,6 @@ export class WorkflowEditor { } container.appendChild(content); - this.pendingApprovalRequest = null; } showApprovalMessage(nodeId: any) { @@ -1933,20 +1947,33 @@ export class WorkflowEditor { const message = document.createElement('div'); message.className = 'chat-message approval-request'; + const labelEl = document.createElement('span'); + labelEl.className = 'chat-message-label'; + labelEl.textContent = 'Approval required'; + message.appendChild(labelEl); + + const body = document.createElement('div'); + body.className = 'approval-body'; + const textEl = document.createElement('div'); textEl.className = 'approval-text'; textEl.textContent = messageText; - message.appendChild(textEl); + body.appendChild(textEl); + + const helperEl = document.createElement('div'); + helperEl.className = 'approval-helper'; + helperEl.textContent = 'Choose how this workflow should proceed.'; + body.appendChild(helperEl); const actions = document.createElement('div'); actions.className = 'approval-actions'; const rejectBtn = document.createElement('button'); - rejectBtn.className = 'button button-danger reject-btn'; + rejectBtn.className = 'button button-secondary reject-btn'; rejectBtn.textContent = 'Reject'; const approveBtn = document.createElement('button'); - approveBtn.className = 'button button-success approve-btn'; + approveBtn.className = 'button button-primary approve-btn'; approveBtn.textContent = 'Approve'; rejectBtn.addEventListener('click', () => this.submitApprovalDecision('reject')); @@ -1954,7 +1981,8 @@ export class WorkflowEditor { actions.appendChild(rejectBtn); actions.appendChild(approveBtn); - message.appendChild(actions); + body.appendChild(actions); + message.appendChild(body); this.chatMessages.appendChild(message); this.chatMessages.scrollTop = this.chatMessages.scrollHeight; @@ -1974,6 +2002,14 @@ export class WorkflowEditor { this.pendingApprovalRequest.rejectBtn.disabled = disabled; } + getApprovalNextNode(nodeId: string, decision: 'approve' | 'reject'): EditorNode | undefined { + const connection = this.connections.find( + (conn: any) => conn.source === nodeId && conn.sourceHandle === decision + ); + if (!connection) return undefined; + return this.nodes.find((node: any) => node.id === connection.target); + } + extractWaitingNodeId(logs: any = []) { if (!Array.isArray(logs)) return null; for (let i = logs.length - 1; i >= 0; i -= 1) { @@ -2022,14 +2058,16 @@ export class WorkflowEditor { startChatSession(_promptText: any) { if (!this.chatMessages) return; this.chatMessages.innerHTML = ''; - this.showAgentSpinner(); + if (typeof _promptText === 'string' && _promptText.trim()) { + this.appendChatMessage(_promptText, 'user'); + } } mapLogEntryToRole(entry: any) { const type = entry.type || ''; if (type.includes('llm_response')) return 'agent'; if (type.includes('llm_error') || type === 'error') return 'error'; - if (type.includes('input_received') || type.includes('start_prompt')) return 'user'; + if (type.includes('input_received')) return 'user'; return null; } @@ -2038,6 +2076,34 @@ export class WorkflowEditor { return typeof content === 'string' ? content : ''; } + getInitialPromptFromLogs(logs: any[] = []): string | null { + if (!Array.isArray(logs)) return null; + const entry = logs.find((item: any) => item?.type === 'start_prompt' && typeof item.content === 'string' && item.content.trim()); + return entry?.content ?? null; + } + + isApprovalInputLog(entry: any): boolean { + if (!entry || entry.type !== 'input_received') return false; + const node = this.nodes.find((n: any) => n.id === entry.nodeId); + return node?.type === 'approval' || node?.type === 'input'; + } + + parseApprovalInputLog(content: string): { decision: 'approve' | 'reject'; note: string } { + const decision = content.toLowerCase().includes('rejected') ? 'reject' : 'approve'; + const feedbackMatch = content.match(/feedback:\s*(.*)$/i); + const note = feedbackMatch?.[1]?.trim() || ''; + return { decision, note }; + } + + appendApprovalResultFromLog(content: string): void { + if (!this.chatMessages) return; + const { decision, note } = this.parseApprovalInputLog(content); + const message = document.createElement('div'); + this.renderApprovalResultCard(message, decision, note); + this.chatMessages.appendChild(message); + this.chatMessages.scrollTop = this.chatMessages.scrollHeight; + } + getAgentNameForNode(nodeId: string): string { const node = this.nodes.find((n: any) => n.id === nodeId); return (node?.data?.agentName || '').trim() || 'Agent'; @@ -2050,13 +2116,6 @@ export class WorkflowEditor { if (node?.type === 'agent') { this.showAgentSpinner(this.getAgentNameForNode(entry.nodeId)); } - } else if (type === 'start_prompt') { - // Only show the user's initial input (before any agent has responded) - if (entry.content && this.lastLlmResponseContent === null) { - this.hideAgentSpinner(); - this.appendChatMessage(entry.content, 'user'); - this.showAgentSpinner(this.getAgentNameForNode(entry.nodeId)); - } } else if (type === 'llm_response') { this.hideAgentSpinner(); this.lastLlmResponseContent = entry.content ?? null; @@ -2071,12 +2130,21 @@ export class WorkflowEditor { if (!this.chatMessages) return; this.chatMessages.innerHTML = ''; this.lastLlmResponseContent = null; + const initialPromptFromLogs = this.getInitialPromptFromLogs(logs); + if (initialPromptFromLogs) { + this.appendChatMessage(initialPromptFromLogs, 'user'); + } let messageShown = false; logs.forEach((entry: any) => { + if (this.isApprovalInputLog(entry)) { + const approvalText = this.formatLogContent(entry); + if (approvalText) { + this.appendApprovalResultFromLog(approvalText); + } + return; + } const role = this.mapLogEntryToRole(entry); if (!role) return; - // Only show the user's initial input (before any agent has responded) - if (entry.type === 'start_prompt' && this.lastLlmResponseContent !== null) return; if (entry.type === 'llm_response') this.lastLlmResponseContent = entry.content ?? null; if ((role === 'agent' || role === 'error') && !messageShown) { this.hideAgentSpinner(); @@ -2150,6 +2218,7 @@ export class WorkflowEditor { : false; if (result.status === 'paused' && result.waitingForInput) { + this.hideAgentSpinner(); this.currentRunId = result.runId; const pausedNodeId = result.currentNodeId || this.extractWaitingNodeId(result.logs); this.showApprovalMessage(pausedNodeId); @@ -2252,11 +2321,17 @@ export class WorkflowEditor { async submitApprovalDecision(decision: any) { if (!this.currentRunId) return; + const pendingApprovalNodeId = this.pendingApprovalRequest?.nodeId ?? null; this.setApprovalButtonsDisabled(true); const note = ''; this.replaceApprovalWithResult(decision, note); this.setWorkflowState('running'); - this.showAgentSpinner(); + if (pendingApprovalNodeId) { + const nextNode = this.getApprovalNextNode(pendingApprovalNodeId, decision); + if (nextNode?.type === 'agent') { + this.showAgentSpinner(this.getAgentNameForNode(nextNode.id)); + } + } const controller = new AbortController(); this.activeRunController = controller; diff --git a/apps/web/src/workflow-editor.css b/apps/web/src/workflow-editor.css index 9730154..61d4e04 100644 --- a/apps/web/src/workflow-editor.css +++ b/apps/web/src/workflow-editor.css @@ -838,79 +838,92 @@ path.connection-line.active { border: 1px solid var(--Colors-Stroke-Default); background: var(--Colors-Backgrounds-Main-Top); color: var(--Colors-Text-Body-Strong); + border-right: 3px solid var(--Colors-Primary-Medium); + max-width: min(520px, 100%); +} + +.approval-body { + display: flex; + flex-direction: column; + gap: var(--UI-Spacing-spacing-s); } .approval-text { - font-weight: 600; - margin-bottom: var(--UI-Spacing-spacing-s); + font-weight: 500; +} + +.approval-helper { + color: var(--Colors-Text-Body-Medium); + font-size: var(--Fonts-Body-Default-xxs); } .approval-actions { display: flex; - justify-content: flex-end; + justify-content: flex-start; + flex-wrap: wrap; gap: var(--UI-Spacing-spacing-s); - margin-top: var(--UI-Spacing-spacing-s); } .approval-actions button { - min-width: 60px; - padding: var(--UI-Spacing-spacing-xxs) var(--UI-Spacing-spacing-mxs); + min-width: 96px; + padding: 0 var(--UI-Spacing-spacing-ms); font-size: var(--Fonts-Body-Default-xs); } .chat-message.approval-result { - align-self: center; - max-width: 400px; - border-radius: var(--UI-Radius-radius-s); - padding: var(--UI-Spacing-spacing-mxs) var(--UI-Spacing-spacing-ms); - font-size: var(--Fonts-Body-Default-xs); - display: flex; - align-items: center; - gap: var(--UI-Spacing-spacing-s); + align-self: flex-end; + border: 1px solid var(--Colors-Stroke-Default); + background: var(--Colors-Backgrounds-Main-Top); + max-width: min(520px, 100%); } .chat-message.approval-result.approved { - background: var(--Colors-Alert-Success-Lighter); - border: 1px solid var(--Colors-Alert-Success-Medium); - color: var(--Colors-Alert-Success-Medium-Dark); + border-right: 3px solid var(--Colors-Alert-Success-Medium); } .chat-message.approval-result.rejected { - background: var(--Colors-Alert-Error-Lighter); - border: 1px solid var(--Colors-Alert-Error-Medium); - color: var(--Colors-Alert-Error-Default); + border-right: 3px solid var(--Colors-Alert-Error-Medium); } .approval-result-content { display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--UI-Spacing-spacing-xs); +} + +.approval-result-status { + display: inline-flex; align-items: center; - gap: var(--UI-Spacing-spacing-s); - flex-wrap: wrap; + gap: var(--UI-Spacing-spacing-xs); + font-weight: 600; +} + +.chat-message.approval-result.approved .approval-result-status { + color: var(--Colors-Alert-Success-Medium-Dark); +} + +.chat-message.approval-result.rejected .approval-result-status { + color: var(--Colors-Alert-Error-Default); } .approval-result-icon { - font-size: var(--Fonts-Body-Default-xl); + font-size: var(--Fonts-Body-Default-sm); font-weight: 700; line-height: 1; } .approval-result-text { font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - font-size: var(--Fonts-Body-Default-xs); + text-transform: none; + letter-spacing: normal; + font-size: var(--Fonts-Body-Default-sm); } .approval-result-note { - width: 100%; - margin-top: var(--UI-Spacing-spacing-s); - padding-top: var(--UI-Spacing-spacing-s); - border-top: 1px solid currentColor; - opacity: 0.7; + color: var(--Colors-Text-Body-Medium); font-size: var(--Fonts-Body-Default-xs); font-weight: 400; - text-transform: none; - letter-spacing: normal; } .chat-input { From 7dfb4ff6c6e08e1ece1589dbb836099adf0cee3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Galv=C3=A3o?= Date: Wed, 25 Feb 2026 14:36:46 -0300 Subject: [PATCH 06/11] fix(web): align condition fallback port to add button --- apps/web/src/app/workflow-editor.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/web/src/app/workflow-editor.ts b/apps/web/src/app/workflow-editor.ts index 3b880e1..84637d7 100644 --- a/apps/web/src/app/workflow-editor.ts +++ b/apps/web/src/app/workflow-editor.ts @@ -506,6 +506,10 @@ export class WorkflowEditor { } const conditionRows = Array.from(nodeEl.querySelectorAll('.condition-row')) as HTMLElement[]; + const addConditionButton = nodeEl.querySelector('.add-condition-btn') as HTMLElement | null; + if (addConditionButton) { + return Math.round(addConditionButton.offsetTop + (addConditionButton.offsetHeight / 2) - 6); + } if (conditionRows.length === 0) { return this.getIfPortTop(conditions.length); } From 31781522ed27eaf722b3248ead887536da009ec4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Galv=C3=A3o?= Date: Wed, 25 Feb 2026 14:52:08 -0300 Subject: [PATCH 07/11] feat(web): highlight previous-output token in agent input --- apps/web/src/app/workflow-editor.ts | 39 ++++++++++++- apps/web/src/workflow-editor.css | 89 +++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 3 deletions(-) diff --git a/apps/web/src/app/workflow-editor.ts b/apps/web/src/app/workflow-editor.ts index 84637d7..fa9c931 100644 --- a/apps/web/src/app/workflow-editor.ts +++ b/apps/web/src/app/workflow-editor.ts @@ -22,6 +22,7 @@ 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 PREVIOUS_OUTPUT_TEMPLATE = '{{PREVIOUS_OUTPUT}}'; const IF_CONDITION_OPERATORS = [ { value: 'equal', label: 'Equal' }, { value: 'contains', label: 'Contains' } @@ -450,6 +451,14 @@ export class WorkflowEditor { return el ? (el.offsetWidth || DEFAULT_NODE_WIDTH) : DEFAULT_NODE_WIDTH; } + getUserPromptHighlightHTML(value: string): string { + const escapedValue = escapeHtml(value || ''); + return escapedValue.replace( + /\{\{PREVIOUS_OUTPUT\}\}/g, + '{{PREVIOUS_OUTPUT}}', + ); + } + normalizeIfCondition(condition: unknown): IfCondition { const asRecord = (typeof condition === 'object' && condition !== null) ? condition as Partial : undefined; const candidateOperator = asRecord?.operator; @@ -1470,15 +1479,39 @@ export class WorkflowEditor { // Input container.appendChild(buildLabel('Input')); + const userInputWrapper = document.createElement('div'); + userInputWrapper.className = 'prompt-highlight-wrapper'; + const userInputHighlight = document.createElement('div'); + userInputHighlight.className = 'prompt-highlight-backdrop'; + userInputHighlight.setAttribute('aria-hidden', 'true'); + const userInputHighlightContent = document.createElement('div'); + userInputHighlightContent.className = 'prompt-highlight-content'; + userInputHighlight.appendChild(userInputHighlightContent); const userInput = document.createElement('textarea'); - userInput.className = 'input textarea-input'; + userInput.className = 'input textarea-input prompt-highlight-input'; userInput.placeholder = 'Use {{PREVIOUS_OUTPUT}} to include the previous node\'s output.'; - userInput.value = data.userPrompt ?? '{{PREVIOUS_OUTPUT}}'; + userInput.value = data.userPrompt ?? PREVIOUS_OUTPUT_TEMPLATE; + const syncUserPromptHighlight = () => { + userInputHighlightContent.innerHTML = this.getUserPromptHighlightHTML(userInput.value); + userInputHighlightContent.style.transform = `translate(${-userInput.scrollLeft}px, ${-userInput.scrollTop}px)`; + }; + syncUserPromptHighlight(); + userInput.addEventListener('focus', () => { + userInputWrapper.classList.add('is-editing'); + }); + userInput.addEventListener('blur', () => { + userInputWrapper.classList.remove('is-editing'); + syncUserPromptHighlight(); + }); userInput.addEventListener('input', (e: any) => { data.userPrompt = e.target.value; + syncUserPromptHighlight(); this.scheduleSave(); }); - container.appendChild(userInput); + userInput.addEventListener('scroll', syncUserPromptHighlight); + userInputWrapper.appendChild(userInputHighlight); + userInputWrapper.appendChild(userInput); + container.appendChild(userInputWrapper); // Model container.appendChild(buildLabel('Model')); diff --git a/apps/web/src/workflow-editor.css b/apps/web/src/workflow-editor.css index 61d4e04..6cfe60b 100644 --- a/apps/web/src/workflow-editor.css +++ b/apps/web/src/workflow-editor.css @@ -511,6 +511,95 @@ path.connection-line.active { line-height: 1.4; } +.prompt-highlight-wrapper { + position: relative; + width: 100%; + border-radius: var(--UI-Radius-radius-s); + background: var(--Colors-Input-Background-Default); + border: 1px solid var(--Colors-Input-Border-Default); + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.prompt-highlight-wrapper:hover { + border-color: var(--Colors-Input-Border-Hover); +} + +.prompt-highlight-wrapper:focus-within { + border-color: var(--Colors-Input-Border-Focus); + box-shadow: 0 0 0 4px var(--Colors-Input-Shadow-Focus); +} + +.prompt-highlight-backdrop { + position: absolute; + inset: 0; + pointer-events: none; + overflow: hidden; + border-radius: var(--UI-Radius-radius-s); + padding: var(--UI-Spacing-spacing-mxs) var(--UI-Spacing-spacing-ms); +} + +.prompt-highlight-content { + min-height: 100%; + white-space: pre-wrap; + overflow-wrap: anywhere; + color: var(--Colors-Input-Text-Default); + font-family: var(--body-family); + font-size: var(--Fonts-Body-Default-md); + font-style: normal; + font-weight: 400; + letter-spacing: -0.16px; + line-height: 1.4; + will-change: transform; +} + +.prompt-highlight-token { + color: var(--Colors-Primary-Medium); + font-weight: 600; +} + +.prompt-highlight-input { + position: relative; + z-index: 1; + background: transparent; + border: none; + box-shadow: none; + color: transparent; + caret-color: var(--Colors-Input-Text-Default); + -webkit-text-fill-color: transparent; +} + +.prompt-highlight-input:hover, +.prompt-highlight-input:focus { + border: none; + box-shadow: none; +} + +.prompt-highlight-input::placeholder { + color: var(--Colors-Input-Text-Placeholder); + -webkit-text-fill-color: var(--Colors-Input-Text-Placeholder); +} + +.prompt-highlight-wrapper:not(.is-editing) .prompt-highlight-input::selection { + background: var(--Colors-Primary-Lightest); + color: transparent; + -webkit-text-fill-color: transparent; +} + +.prompt-highlight-wrapper.is-editing .prompt-highlight-backdrop { + display: none; +} + +.prompt-highlight-wrapper.is-editing .prompt-highlight-input { + color: var(--Colors-Input-Text-Default); + -webkit-text-fill-color: var(--Colors-Input-Text-Default); +} + +.prompt-highlight-wrapper.is-editing .prompt-highlight-input::selection { + background: rgba(92, 148, 255, 0.24); + color: inherit; + -webkit-text-fill-color: inherit; +} + .ds-select { width: 100%; height: var(--UI-Input-sm); From 46ec45913e9a40df19fbaee42332acfc5d274734 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Galv=C3=A3o?= Date: Wed, 25 Feb 2026 15:01:36 -0300 Subject: [PATCH 08/11] feat(web): duplicate agent node from header control --- apps/web/src/app/workflow-editor.ts | 51 ++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/workflow-editor.ts b/apps/web/src/app/workflow-editor.ts index fa9c931..1479f0b 100644 --- a/apps/web/src/app/workflow-editor.ts +++ b/apps/web/src/app/workflow-editor.ts @@ -1245,6 +1245,40 @@ export class WorkflowEditor { this.updateRunButton(); } + duplicateAgentNode(sourceNode: EditorNode): void { + if (sourceNode.type !== 'agent') return; + const duplicatedData = sourceNode.data + ? JSON.parse(JSON.stringify(sourceNode.data)) as WorkflowNodeData + : {}; + duplicatedData.collapsed = true; + + const sourceEl = document.getElementById(sourceNode.id); + const sourceHeight = sourceEl?.offsetHeight + ?? (sourceNode.data?.collapsed ? 96 : 240); + const duplicatedCollapsedHeight = 96; + const duplicateSpacing = 24; + const minWorldY = 16; + const duplicateX = sourceNode.x; + const proposedAboveY = sourceNode.y - duplicatedCollapsedHeight - duplicateSpacing; + const duplicateY = proposedAboveY >= minWorldY + ? proposedAboveY + : sourceNode.y + sourceHeight + duplicateSpacing; + + const duplicatedNode: EditorNode = { + id: `node_${this.nextNodeId++}`, + type: 'agent', + x: duplicateX, + y: duplicateY, + data: duplicatedData + }; + + this.nodes.push(duplicatedNode); + this.renderNode(duplicatedNode); + this.selectNode(duplicatedNode.id); + this.scheduleSave(); + this.updateRunButton(); + } + // --- RENDERING --- render() { @@ -1284,6 +1318,20 @@ export class WorkflowEditor { const controls = document.createElement('div'); controls.className = 'node-controls'; + let duplicateBtn: HTMLButtonElement | null = null; + if (node.type === 'agent') { + duplicateBtn = document.createElement('button'); + duplicateBtn.type = 'button'; + duplicateBtn.className = 'button button-tertiary button-small icon-btn duplicate'; + duplicateBtn.innerHTML = ''; + duplicateBtn.title = 'Duplicate Agent'; + duplicateBtn.addEventListener('mousedown', (e: any) => { + e.stopPropagation(); + this.duplicateAgentNode(node); + }); + controls.appendChild(duplicateBtn); + } + let collapseBtn: HTMLButtonElement | null = null; let updateCollapseIcon = () => {}; if (hasSettings) { @@ -1330,9 +1378,10 @@ export class WorkflowEditor { // Drag Handler header.addEventListener('mousedown', (e: any) => { + const interactingWithDuplicate = duplicateBtn && duplicateBtn.contains(e.target); const interactingWithCollapse = collapseBtn && collapseBtn.contains(e.target); const interactingWithDelete = delBtn && delBtn.contains(e.target); - if (interactingWithCollapse || interactingWithDelete) return; + if (interactingWithDuplicate || interactingWithCollapse || interactingWithDelete) return; e.stopPropagation(); this.selectNode(node.id); From 992da28a55cebada392a929c42014830455c7b7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Galv=C3=A3o?= Date: Wed, 25 Feb 2026 15:03:41 -0300 Subject: [PATCH 09/11] feat(web): add tooltips to node header actions --- apps/web/src/app/workflow-editor.ts | 6 ++++- apps/web/src/workflow-editor.css | 41 +++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/workflow-editor.ts b/apps/web/src/app/workflow-editor.ts index 1479f0b..62cf5c8 100644 --- a/apps/web/src/app/workflow-editor.ts +++ b/apps/web/src/app/workflow-editor.ts @@ -1325,6 +1325,7 @@ export class WorkflowEditor { duplicateBtn.className = 'button button-tertiary button-small icon-btn duplicate'; duplicateBtn.innerHTML = ''; duplicateBtn.title = 'Duplicate Agent'; + duplicateBtn.setAttribute('data-tooltip', 'Duplicate Agent'); duplicateBtn.addEventListener('mousedown', (e: any) => { e.stopPropagation(); this.duplicateAgentNode(node); @@ -1341,7 +1342,9 @@ export class WorkflowEditor { collapseBtn.innerHTML = ''; updateCollapseIcon = () => { if (!collapseBtn) return; - collapseBtn.title = node.data.collapsed ? 'Open settings' : 'Close settings'; + const tooltip = node.data.collapsed ? 'Open settings' : 'Close settings'; + collapseBtn.title = tooltip; + collapseBtn.setAttribute('data-tooltip', tooltip); el.classList.toggle('expanded', !node.data.collapsed); }; updateCollapseIcon(); @@ -1362,6 +1365,7 @@ export class WorkflowEditor { delBtn.className = 'button button-tertiary button-small icon-btn delete'; delBtn.innerHTML = ''; delBtn.title = 'Delete Node'; + delBtn.setAttribute('data-tooltip', 'Delete Node'); delBtn.addEventListener('mousedown', async (e: any) => { e.stopPropagation(); const confirmed = await this.openConfirmModal({ diff --git a/apps/web/src/workflow-editor.css b/apps/web/src/workflow-editor.css index 6cfe60b..9189494 100644 --- a/apps/web/src/workflow-editor.css +++ b/apps/web/src/workflow-editor.css @@ -369,6 +369,47 @@ path.connection-line.active { font-size: var(--Fonts-Body-Default-xxs); } +.icon-btn[data-tooltip] { + position: relative; + overflow: visible; +} + +.icon-btn[data-tooltip]:hover::after, +.icon-btn[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: 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: 40; + box-shadow: 0 3px 2px 0 var(--Colors-Shadow-Card); + pointer-events: none; +} + +.icon-btn[data-tooltip]:hover::before, +.icon-btn[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: 41; + pointer-events: none; +} + /* Node Body (Properties Form) */ .node-body { padding: var(--UI-Spacing-spacing-ms); From f337698bc6f13cb35c433760373166e64e4e8f9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Galv=C3=A3o?= Date: Wed, 25 Feb 2026 15:37:25 -0300 Subject: [PATCH 10/11] fix(workflow): isolate active run graph from live canvas edits --- apps/server/src/routes/workflows.ts | 23 +++++-- apps/web/src/app/workflow-editor.ts | 96 ++++++++++++++++++++++++----- apps/web/src/workflow-editor.css | 2 +- packages/types/src/index.d.ts | 1 + packages/types/src/index.ts | 2 +- 5 files changed, 101 insertions(+), 23 deletions(-) diff --git a/apps/server/src/routes/workflows.ts b/apps/server/src/routes/workflows.ts index 39dc537..2023782 100644 --- a/apps/server/src/routes/workflows.ts +++ b/apps/server/src/routes/workflows.ts @@ -44,6 +44,15 @@ async function persistResult(engine: WorkflowEngine, result: WorkflowRunResult) } } +function getEngineWorkflow(engine: WorkflowEngine): WorkflowGraph | undefined { + // Backward compatibility: fall back to private graph if getGraph is unavailable. + const engineAny = engine as WorkflowEngine & { getGraph?: () => WorkflowGraph }; + if (typeof engineAny.getGraph === 'function') { + return engineAny.getGraph(); + } + return Reflect.get(engine, 'graph') as WorkflowGraph | undefined; +} + export function createWorkflowRouter(llm?: WorkflowLLM): Router { const router = createRouter(); @@ -75,7 +84,8 @@ export function createWorkflowRouter(llm?: WorkflowLLM): Router { removeWorkflow(runId); } - res.json(result); + const workflow = getEngineWorkflow(engine) ?? graph; + res.json({ ...result, workflow }); } catch (error) { const message = error instanceof Error ? error.message : String(error); logger.error('Failed to execute workflow', message); @@ -129,7 +139,8 @@ export function createWorkflowRouter(llm?: WorkflowLLM): Router { removeWorkflow(runId); } - sendEvent({ type: 'done', result }); + const workflow = getEngineWorkflow(engine) ?? graph; + sendEvent({ type: 'done', result: { ...result, workflow } }); } catch (error) { const message = error instanceof Error ? error.message : String(error); logger.error('Failed to execute workflow stream', message); @@ -160,7 +171,8 @@ export function createWorkflowRouter(llm?: WorkflowLLM): Router { removeWorkflow(runId); } - res.json(result); + const workflow = getEngineWorkflow(engine); + res.json(workflow ? { ...result, workflow } : result); } catch (error) { const message = error instanceof Error ? error.message : String(error); logger.error('Failed to resume workflow', message); @@ -201,7 +213,9 @@ export function createWorkflowRouter(llm?: WorkflowLLM): Router { // Check in-memory first — catches engines that are still running or paused const engine = getWorkflow(runId); if (engine) { - res.json(engine.getResult()); + const result = engine.getResult(); + const workflow = getEngineWorkflow(engine); + res.json(workflow ? { ...result, workflow } : result); return; } @@ -221,6 +235,7 @@ export function createWorkflowRouter(llm?: WorkflowLLM): Router { state: record.state ?? {}, waitingForInput: record.waitingForInput ?? false, currentNodeId: record.currentNodeId ?? null, + workflow: record.workflow }; res.json(result); } catch (error) { diff --git a/apps/web/src/app/workflow-editor.ts b/apps/web/src/app/workflow-editor.ts index 62cf5c8..9caf312 100644 --- a/apps/web/src/app/workflow-editor.ts +++ b/apps/web/src/app/workflow-editor.ts @@ -1,6 +1,6 @@ // Bespoke Agent Builder - Client Logic -import type { WorkflowConnection, WorkflowNode, WorkflowRunResult } from '@agentic/types'; +import type { WorkflowConnection, WorkflowGraph, WorkflowNode, WorkflowRunResult } from '@agentic/types'; import { runWorkflowStream, resumeWorkflow, fetchConfig, fetchRun } from '../services/api'; import { renderMarkdown, escapeHtml } from './markdown'; @@ -223,6 +223,8 @@ export class WorkflowEditor { private currentRunId: string | null; + private activeRunGraph: WorkflowGraphPayload | null; + private runHistory: RunHistoryEntry[]; private getErrorMessage(error: unknown): string { @@ -241,6 +243,36 @@ export class WorkflowEditor { }); } + private cloneGraphPayload(graph: WorkflowGraphPayload): WorkflowGraphPayload { + return JSON.parse(JSON.stringify(graph)) as WorkflowGraphPayload; + } + + private setActiveRunGraph(graph: WorkflowGraphPayload | null): void { + this.activeRunGraph = graph ? this.cloneGraphPayload(graph) : null; + } + + private syncActiveRunGraphFromResult(result: WorkflowRunResult): void { + const workflow: WorkflowGraph | undefined = result.workflow; + if (!workflow || !Array.isArray(workflow.nodes) || !Array.isArray(workflow.connections)) return; + this.setActiveRunGraph({ + nodes: workflow.nodes as EditorNode[], + connections: workflow.connections + }); + } + + private getRunNodes(): EditorNode[] { + return this.activeRunGraph?.nodes ?? this.nodes; + } + + private getRunConnections(): WorkflowConnection[] { + return this.activeRunGraph?.connections ?? this.connections; + } + + private getRunNodeById(nodeId: string | null | undefined): EditorNode | undefined { + if (!nodeId) return undefined; + return this.getRunNodes().find((node) => node.id === nodeId); + } + constructor() { this.modelOptions = [...DEFAULT_MODEL_OPTIONS]; this.modelEfforts = { ...DEFAULT_MODEL_EFFORTS }; @@ -279,6 +311,7 @@ export class WorkflowEditor { this.activeRunController = null; this.lastLlmResponseContent = null; this.currentRunId = null; + this.activeRunGraph = null; this.runHistory = []; this.splitPanelCtorPromise = null; @@ -431,7 +464,7 @@ export class WorkflowEditor { } getPrimaryAgentName() { - const agentNode = this.nodes.find((n) => n.type === 'agent'); + const agentNode = this.getRunNodes().find((n) => n.type === 'agent'); if (agentNode && agentNode.data) { const name = (agentNode.data.agentName || '').trim(); if (name) return name; @@ -651,6 +684,7 @@ export class WorkflowEditor { this.clearApprovalMessage(); this.appendStatusMessage('Cancelled'); this.currentRunId = null; + this.setActiveRunGraph(null); this.setWorkflowState('idle'); } } @@ -724,6 +758,16 @@ export class WorkflowEditor { return null; } + getClearDisableReason(): string | null { + if (this.workflowState === 'running') { + return 'Cannot clear canvas while workflow is running.'; + } + if (this.workflowState === 'paused') { + return 'Cannot clear canvas while workflow is paused waiting for approval.'; + } + return null; + } + updateRunButton() { if (!this.runButton) return; if (this.cancelRunButton) { @@ -733,9 +777,7 @@ export class WorkflowEditor { } if (this.clearButton) { - const clearDisabledReason = this.workflowState === 'running' - ? 'Cannot clear canvas while workflow is running.' - : null; + const clearDisabledReason = this.getClearDisableReason(); this.clearButton.disabled = Boolean(clearDisabledReason); this.setClearButtonHint(clearDisabledReason); } @@ -935,7 +977,7 @@ export class WorkflowEditor { } if (this.clearButton) { this.clearButton.addEventListener('click', async () => { - if (this.workflowState === 'running') return; + if (this.workflowState !== 'idle') return; const confirmed = await this.openConfirmModal({ title: 'Clear Canvas', message: 'Remove all nodes and connections from the canvas?', @@ -1326,7 +1368,8 @@ export class WorkflowEditor { duplicateBtn.innerHTML = ''; duplicateBtn.title = 'Duplicate Agent'; duplicateBtn.setAttribute('data-tooltip', 'Duplicate Agent'); - duplicateBtn.addEventListener('mousedown', (e: any) => { + duplicateBtn.setAttribute('aria-label', 'Duplicate Agent'); + duplicateBtn.addEventListener('click', (e: any) => { e.stopPropagation(); this.duplicateAgentNode(node); }); @@ -2031,7 +2074,7 @@ export class WorkflowEditor { showApprovalMessage(nodeId: any) { if (!this.chatMessages) return; this.clearApprovalMessage(); - const node = this.nodes.find((n: any) => n.id === nodeId); + const node = this.getRunNodeById(nodeId); const messageText = node?.data?.prompt || 'Approval required before continuing.'; const message = document.createElement('div'); @@ -2093,11 +2136,11 @@ export class WorkflowEditor { } getApprovalNextNode(nodeId: string, decision: 'approve' | 'reject'): EditorNode | undefined { - const connection = this.connections.find( + const connection = this.getRunConnections().find( (conn: any) => conn.source === nodeId && conn.sourceHandle === decision ); if (!connection) return undefined; - return this.nodes.find((node: any) => node.id === connection.target); + return this.getRunNodes().find((node: any) => node.id === connection.target); } extractWaitingNodeId(logs: any = []) { @@ -2174,12 +2217,15 @@ export class WorkflowEditor { isApprovalInputLog(entry: any): boolean { if (!entry || entry.type !== 'input_received') return false; - const node = this.nodes.find((n: any) => n.id === entry.nodeId); + const node = this.getRunNodeById(entry.nodeId); return node?.type === 'approval' || node?.type === 'input'; } parseApprovalInputLog(content: string): { decision: 'approve' | 'reject'; note: string } { - const decision = content.toLowerCase().includes('rejected') ? 'reject' : 'approve'; + const decisionPrefixMatch = content.match(/(?:^|\n)\s*(?:Decision|Status)\s*:\s*(approve|approved|reject|rejected)\b/i); + const sentencePrefixMatch = content.match(/(?:^|\n)\s*User\s+(approved|rejected)\b/i); + const rawDecision = (decisionPrefixMatch?.[1] || sentencePrefixMatch?.[1] || 'approve').toLowerCase(); + const decision = rawDecision.startsWith('reject') ? 'reject' : 'approve'; const feedbackMatch = content.match(/feedback:\s*(.*)$/i); const note = feedbackMatch?.[1]?.trim() || ''; return { decision, note }; @@ -2195,14 +2241,14 @@ export class WorkflowEditor { } getAgentNameForNode(nodeId: string): string { - const node = this.nodes.find((n: any) => n.id === nodeId); + const node = this.getRunNodeById(nodeId); return (node?.data?.agentName || '').trim() || 'Agent'; } onLogEntry(entry: any) { const type = entry.type || ''; if (type === 'step_start') { - const node = this.nodes.find((n: any) => n.id === entry.nodeId); + const node = this.getRunNodeById(entry.nodeId); if (node?.type === 'agent') { this.showAgentSpinner(this.getAgentNameForNode(entry.nodeId)); } @@ -2271,10 +2317,11 @@ export class WorkflowEditor { if (!startNode.data) startNode.data = {}; startNode.data.initialInput = this.currentPrompt; - const graph = { + const graph = this.cloneGraphPayload({ nodes: this.nodes, connections: this.connections - }; + }); + this.setActiveRunGraph(graph); const controller = new AbortController(); this.activeRunController = controller; @@ -2291,6 +2338,7 @@ export class WorkflowEditor { this.appendChatMessage(this.getErrorMessage(e), 'error'); this.appendStatusMessage('Failed', 'failed'); this.hideAgentSpinner(); + this.setActiveRunGraph(null); this.setWorkflowState('idle'); } finally { if (this.activeRunController === controller) { @@ -2300,6 +2348,7 @@ export class WorkflowEditor { } handleRunResult(result: WorkflowRunResult, fromStream = false) { + this.syncActiveRunGraphFromResult(result); if (!fromStream && result.logs) { this.renderChatFromLogs(result.logs); } @@ -2324,6 +2373,7 @@ export class WorkflowEditor { this.hideAgentSpinner(); this.setWorkflowState('idle'); this.currentRunId = null; + this.setActiveRunGraph(null); } else if (result.status === 'failed') { this.clearRunId(); this.clearApprovalMessage(); @@ -2331,10 +2381,12 @@ export class WorkflowEditor { this.hideAgentSpinner(); this.setWorkflowState('idle'); this.currentRunId = null; + this.setActiveRunGraph(null); } else { this.clearApprovalMessage(); this.hideAgentSpinner(); this.setWorkflowState('idle'); + this.setActiveRunGraph(null); } } @@ -2350,7 +2402,13 @@ export class WorkflowEditor { // runId so recovery can be reattempted on the next page load. return; } - if (!result) { this.clearRunId(); return; } // 404 — run genuinely gone + if (!result) { + this.clearRunId(); + this.setActiveRunGraph(null); + return; + } // 404 — run genuinely gone + + this.syncActiveRunGraphFromResult(result); if (result.status === 'running') { // Engine still executing on server — show partial chat and poll for updates @@ -2366,6 +2424,7 @@ export class WorkflowEditor { this.renderChatFromLogs(result.logs); this.appendStatusMessage('Previous paused run was lost (server restarted).', 'failed'); this.setWorkflowState('idle'); + this.setActiveRunGraph(null); } else { // completed, failed, or paused-with-waitingForInput — // handleRunResult covers all three cases @@ -2393,8 +2452,10 @@ export class WorkflowEditor { // 404 — run is genuinely gone from server and disk this.clearRunId(); this.setWorkflowState('idle'); + this.setActiveRunGraph(null); return; } + this.syncActiveRunGraphFromResult(result); // Re-render chat if new log entries arrived since last poll const logs = Array.isArray(result.logs) ? result.logs : []; if (logs.length > knownLogCount) { @@ -2434,6 +2495,7 @@ export class WorkflowEditor { this.appendStatusMessage('Failed', 'failed'); this.hideAgentSpinner(); this.setWorkflowState('idle'); + this.setActiveRunGraph(null); } finally { if (this.activeRunController === controller) { this.activeRunController = null; diff --git a/apps/web/src/workflow-editor.css b/apps/web/src/workflow-editor.css index 9189494..3fd9353 100644 --- a/apps/web/src/workflow-editor.css +++ b/apps/web/src/workflow-editor.css @@ -636,7 +636,7 @@ path.connection-line.active { } .prompt-highlight-wrapper.is-editing .prompt-highlight-input::selection { - background: rgba(92, 148, 255, 0.24); + background: color-mix(in srgb, var(--Colors-Primary-Medium) 24%, transparent); color: inherit; -webkit-text-fill-color: inherit; } diff --git a/packages/types/src/index.d.ts b/packages/types/src/index.d.ts index b0dcf9a..ec6a3dd 100644 --- a/packages/types/src/index.d.ts +++ b/packages/types/src/index.d.ts @@ -34,6 +34,7 @@ export interface WorkflowRunResult { state: Record; waitingForInput: boolean; currentNodeId: string | null; + workflow?: WorkflowGraph; } export interface ApprovalInput { decision: 'approve' | 'reject'; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index bbf6171..c6bbd73 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -41,6 +41,7 @@ export interface WorkflowRunResult { state: Record; waitingForInput: boolean; currentNodeId: string | null; + workflow?: WorkflowGraph; } export interface WorkflowRunRecord { @@ -60,4 +61,3 @@ export interface ApprovalInput { } export interface WorkflowEngineResult extends WorkflowRunResult {} - From 0351f6e03fbc6f43d9b7862afd863323f0fcec89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Galv=C3=A3o?= Date: Wed, 25 Feb 2026 15:48:16 -0300 Subject: [PATCH 11/11] chore: address coderabbit nitpicks for workflow routes and css --- apps/server/src/routes/workflows.ts | 8 +------- apps/web/src/workflow-editor.css | 2 +- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/apps/server/src/routes/workflows.ts b/apps/server/src/routes/workflows.ts index 2023782..bd5c93f 100644 --- a/apps/server/src/routes/workflows.ts +++ b/apps/server/src/routes/workflows.ts @@ -16,13 +16,7 @@ function validateGraph(graph: WorkflowGraph | undefined): graph is WorkflowGraph async function persistResult(engine: WorkflowEngine, result: WorkflowRunResult) { try { - // Backward compatibility: fall back to reading the private graph field if the engine - // instance doesn't yet expose getGraph (e.g., cached build). - const engineAny = engine as WorkflowEngine & { getGraph?: () => WorkflowGraph }; - const workflow = - typeof engineAny.getGraph === 'function' - ? engineAny.getGraph() - : (Reflect.get(engine, 'graph') as WorkflowGraph | undefined); + const workflow = getEngineWorkflow(engine); if (!workflow) { throw new Error('Workflow graph not available on engine instance'); diff --git a/apps/web/src/workflow-editor.css b/apps/web/src/workflow-editor.css index 3fd9353..14a1e65 100644 --- a/apps/web/src/workflow-editor.css +++ b/apps/web/src/workflow-editor.css @@ -486,7 +486,7 @@ path.connection-line.active { align-items: center; justify-content: center; padding: 0; - font-size: 10px; + font-size: var(--Fonts-Body-Default-xxs); font-weight: 700; color: var(--Colors-Primary-Medium); line-height: 1;