diff --git a/apps/server/src/routes/workflows.ts b/apps/server/src/routes/workflows.ts index 39dc537..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'); @@ -44,6 +38,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 +78,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 +133,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 +165,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 +207,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 +229,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 bfe9e8f..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'; @@ -20,6 +20,9 @@ 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 PREVIOUS_OUTPUT_TEMPLATE = '{{PREVIOUS_OUTPUT}}'; const IF_CONDITION_OPERATORS = [ { value: 'equal', label: 'Equal' }, { value: 'contains', label: 'Contains' } @@ -186,6 +189,8 @@ export class WorkflowEditor { private cancelRunButton: HTMLButtonElement | null; + private clearButton: HTMLButtonElement | null; + private zoomValue: HTMLElement | null; private workflowState: WorkflowState; @@ -218,6 +223,8 @@ export class WorkflowEditor { private currentRunId: string | null; + private activeRunGraph: WorkflowGraphPayload | null; + private runHistory: RunHistoryEntry[]; private getErrorMessage(error: unknown): string { @@ -236,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 }; @@ -264,6 +301,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'); @@ -273,6 +311,7 @@ export class WorkflowEditor { this.activeRunController = null; this.lastLlmResponseContent = null; this.currentRunId = null; + this.activeRunGraph = null; this.runHistory = []; this.splitPanelCtorPromise = null; @@ -425,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; @@ -445,6 +484,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; @@ -470,6 +517,65 @@ 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[]; + 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); + } + + 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; + } + + 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,12 +620,21 @@ 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; + return this.getIfFallbackPortTop(node) + 6; } const conditionIndex = this.getIfConditionIndexFromHandle(sourceHandle); if (conditionIndex !== null) { - return this.getIfPortTop(conditionIndex) + 6; + return this.getIfConditionPortTop(node, conditionIndex) + 6; } } @@ -542,6 +657,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; @@ -560,6 +684,7 @@ export class WorkflowEditor { this.clearApprovalMessage(); this.appendStatusMessage('Cancelled'); this.currentRunId = null; + this.setActiveRunGraph(null); this.setWorkflowState('idle'); } } @@ -633,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) { @@ -640,6 +775,12 @@ export class WorkflowEditor { this.cancelRunButton.style.display = showCancel ? 'inline-flex' : 'none'; this.cancelRunButton.disabled = !showCancel; } + + if (this.clearButton) { + const clearDisabledReason = this.getClearDisableReason(); + this.clearButton.disabled = Boolean(clearDisabledReason); + this.setClearButtonHint(clearDisabledReason); + } switch (this.workflowState) { case 'running': @@ -834,28 +975,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 !== 'idle') 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'); @@ -1146,6 +1287,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() { @@ -1185,6 +1360,22 @@ 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.setAttribute('data-tooltip', 'Duplicate Agent'); + duplicateBtn.setAttribute('aria-label', 'Duplicate Agent'); + duplicateBtn.addEventListener('click', (e: any) => { + e.stopPropagation(); + this.duplicateAgentNode(node); + }); + controls.appendChild(duplicateBtn); + } + let collapseBtn: HTMLButtonElement | null = null; let updateCollapseIcon = () => {}; if (hasSettings) { @@ -1194,7 +1385,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(); @@ -1202,6 +1395,7 @@ export class WorkflowEditor { e.stopPropagation(); node.data.collapsed = !node.data.collapsed; updateCollapseIcon(); + this.refreshNodePorts(node); this.renderConnections(); }); controls.appendChild(collapseBtn); @@ -1214,6 +1408,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({ @@ -1230,9 +1425,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); @@ -1249,6 +1445,7 @@ export class WorkflowEditor { e.stopPropagation(); node.data.collapsed = !node.data.collapsed; updateCollapseIcon(); + this.refreshNodePorts(node); this.renderConnections(); }); @@ -1266,12 +1463,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) { @@ -1378,15 +1575,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')); @@ -1584,29 +1805,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.getIfConditionPortTop(node, index) + ) + ); + }); + el.appendChild( + this.createPort( + node.id, + IF_FALLBACK_HANDLE, + 'port-out port-condition-fallback', + 'False fallback', + this.getIfFallbackPortTop(node) + ) + ); + } } 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')); @@ -1780,6 +2024,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'); @@ -1788,19 +2037,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'); @@ -1810,32 +2069,44 @@ export class WorkflowEditor { } container.appendChild(content); - this.pendingApprovalRequest = null; } 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'); 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')); @@ -1843,7 +2114,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; @@ -1863,6 +2135,14 @@ export class WorkflowEditor { this.pendingApprovalRequest.rejectBtn.disabled = disabled; } + getApprovalNextNode(nodeId: string, decision: 'approve' | 'reject'): EditorNode | undefined { + const connection = this.getRunConnections().find( + (conn: any) => conn.source === nodeId && conn.sourceHandle === decision + ); + if (!connection) return undefined; + return this.getRunNodes().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) { @@ -1911,14 +2191,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; } @@ -1927,25 +2209,49 @@ 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.getRunNodeById(entry.nodeId); + return node?.type === 'approval' || node?.type === 'input'; + } + + parseApprovalInputLog(content: string): { decision: 'approve' | 'reject'; note: string } { + 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 }; + } + + 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); + 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)); } - } 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; @@ -1960,12 +2266,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(); @@ -2002,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; @@ -2022,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) { @@ -2031,6 +2348,7 @@ export class WorkflowEditor { } handleRunResult(result: WorkflowRunResult, fromStream = false) { + this.syncActiveRunGraphFromResult(result); if (!fromStream && result.logs) { this.renderChatFromLogs(result.logs); } @@ -2039,6 +2357,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); @@ -2054,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(); @@ -2061,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); } } @@ -2080,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 @@ -2096,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 @@ -2123,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) { @@ -2141,11 +2472,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; @@ -2158,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 835c85d..14a1e65 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); @@ -436,6 +477,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: 16px; + height: 16px; + border-radius: 50%; + right: -10px; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + font-size: var(--Fonts-Body-Default-xxs); + 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 */ @@ -497,6 +552,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: color-mix(in srgb, var(--Colors-Primary-Medium) 24%, transparent); + color: inherit; + -webkit-text-fill-color: inherit; +} + .ds-select { width: 100%; height: var(--UI-Input-sm); @@ -824,79 +968,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 { @@ -962,12 +1119,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%; @@ -988,7 +1147,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%; @@ -999,3 +1159,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; +} 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 {} -