: 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 {}
-