diff --git a/config/gni/devtools_grd_files.gni b/config/gni/devtools_grd_files.gni index 05c2456566..12b7243ecb 100644 --- a/config/gni/devtools_grd_files.gni +++ b/config/gni/devtools_grd_files.gni @@ -658,6 +658,7 @@ grd_files_bundled_sources = [ "front_end/panels/ai_chat/ui/mcp/MCPConnectorsCatalogDialog.js", "front_end/panels/ai_chat/ui/EvaluationDialog.js", "front_end/panels/ai_chat/ui/WebAppCodeViewer.js", + "front_end/panels/ai_chat/ui/WorkflowVisualizer.js", "front_end/panels/ai_chat/core/AgentService.js", "front_end/panels/ai_chat/core/State.js", "front_end/panels/ai_chat/core/Graph.js", @@ -673,6 +674,7 @@ grd_files_bundled_sources = [ "front_end/panels/ai_chat/core/ToolSurfaceProvider.js", "front_end/panels/ai_chat/core/ToolNameMap.js", "front_end/panels/ai_chat/core/StateGraph.js", + "front_end/panels/ai_chat/core/GraphConverter.js", "front_end/panels/ai_chat/core/Logger.js", "front_end/panels/ai_chat/core/AgentErrorHandler.js", "front_end/panels/ai_chat/core/AgentDescriptorRegistry.js", diff --git a/front_end/panels/ai_chat/BUILD.gn b/front_end/panels/ai_chat/BUILD.gn index 4ff6a6bd62..f6a5b795ec 100644 --- a/front_end/panels/ai_chat/BUILD.gn +++ b/front_end/panels/ai_chat/BUILD.gn @@ -43,6 +43,7 @@ devtools_module("ai_chat") { "ui/PromptEditDialog.ts", "ui/EvaluationDialog.ts", "ui/WebAppCodeViewer.ts", + "ui/WorkflowVisualizer.ts", "ai_chat_impl.ts", "models/ChatTypes.ts", "core/Graph.ts", @@ -63,6 +64,7 @@ devtools_module("ai_chat") { "core/ToolNameMap.ts", "core/ToolSurfaceProvider.ts", "core/StateGraph.ts", + "core/GraphConverter.ts", "core/Logger.ts", "core/AgentErrorHandler.ts", "core/Version.ts", @@ -208,6 +210,7 @@ _ai_chat_sources = [ "ui/SettingsDialog.ts", "ui/EvaluationDialog.ts", "ui/WebAppCodeViewer.ts", + "ui/WorkflowVisualizer.ts", "ui/mcp/MCPConnectionsDialog.ts", "ui/mcp/MCPConnectorsCatalogDialog.ts", "ai_chat_impl.ts", @@ -230,6 +233,7 @@ _ai_chat_sources = [ "core/ToolNameMap.ts", "core/ToolSurfaceProvider.ts", "core/StateGraph.ts", + "core/GraphConverter.ts", "core/Logger.ts", "core/AgentErrorHandler.ts", "core/Version.ts", @@ -414,6 +418,8 @@ ts_library("unittests") { "ui/__tests__/ChatViewInputClear.test.ts", "ui/__tests__/SettingsDialogOpenRouterCache.test.ts", "ui/__tests__/WebAppCodeViewer.test.ts", + "ui/__tests__/WorkflowVisualizer.test.ts", + "ui/__tests__/ChatViewWorkflowButton.test.ts", "ui/input/__tests__/InputBarClear.test.ts", "ui/message/__tests__/MessageCombiner.test.ts", "ui/message/__tests__/StructuredResponseController.test.ts", @@ -431,6 +437,7 @@ ts_library("unittests") { "core/__tests__/ToolNameMap.test.ts", "core/__tests__/ToolNameMapping.test.ts", "core/__tests__/ToolSurfaceProvider.test.ts", + "core/__tests__/GraphConverter.test.ts", "ui/__tests__/AIChatPanel.test.ts", "ui/__tests__/LiveAgentSessionComponent.test.ts", "ui/message/__tests__/MessageList.test.ts", diff --git a/front_end/panels/ai_chat/core/GraphConverter.ts b/front_end/panels/ai_chat/core/GraphConverter.ts new file mode 100644 index 0000000000..94026bcb47 --- /dev/null +++ b/front_end/panels/ai_chat/core/GraphConverter.ts @@ -0,0 +1,255 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import type { GraphConfig, GraphNodeConfig, GraphEdgeConfig } from './ConfigurableGraph.js'; + +export interface XYFlowNode { + id: string; + type: string; + position: { x: number; y: number }; + data: { + label: string; + nodeType: string; + description?: string; + }; + style?: Record; +} + +export interface XYFlowEdge { + id: string; + source: string; + target: string; + label?: string; + type?: string; + animated?: boolean; + style?: Record; +} + +export interface XYFlowGraphData { + nodes: XYFlowNode[]; + edges: XYFlowEdge[]; +} + +/** + * Converts GraphConfig to XYFlow format with auto-layout + */ +export function convertGraphConfigToXYFlow(config: GraphConfig): XYFlowGraphData { + // Build node map for efficient lookup + const nodeMap = new Map(); + config.nodes.forEach(node => nodeMap.set(node.name, node)); + + // Calculate positions using simple hierarchical layout + const positions = calculateNodePositions(config); + + // Convert nodes + const nodes: XYFlowNode[] = config.nodes.map((node, index) => ({ + id: node.name, + type: getXYFlowNodeType(node.type), + position: positions.get(node.name) || { x: 0, y: index * 100 }, + data: { + label: formatNodeLabel(node.name), + nodeType: node.type, + description: getNodeDescription(node) + }, + style: getNodeStyle(node.type) + })); + + // Convert edges - flatten targetMap into multiple edges + const edges: XYFlowEdge[] = []; + config.edges.forEach((edge, edgeIndex) => { + // Create one XYFlow edge for each target in targetMap + Object.entries(edge.targetMap).forEach(([condition, target], targetIndex) => { + edges.push({ + id: `edge-${edgeIndex}-${targetIndex}`, + source: edge.source, + target: target, + label: condition !== 'default' ? condition : '', + type: edge.conditionType !== 'alwaysAgent' ? 'smoothstep' : 'default', + animated: false, + style: getEdgeStyle(edge, condition) + }); + }); + }); + + return { nodes, edges }; +} + +/** + * Calculate node positions using hierarchical layout algorithm + */ +function calculateNodePositions(config: GraphConfig): Map { + const positions = new Map(); + + // Build adjacency list from targetMap + const adjacencyList = new Map(); + config.edges.forEach(edge => { + if (!adjacencyList.has(edge.source)) { + adjacencyList.set(edge.source, []); + } + // Add all targets from the targetMap + Object.values(edge.targetMap).forEach(target => { + if (target !== '__end__') { + adjacencyList.get(edge.source)!.push(target); + } + }); + }); + + // Topological sort to determine levels + const levels = assignNodesToLevels(config.entryPoint, adjacencyList, config.nodes); + + // Position nodes based on levels + const HORIZONTAL_SPACING = 250; + const VERTICAL_SPACING = 150; + + levels.forEach((nodesAtLevel, level) => { + const totalWidth = (nodesAtLevel.length - 1) * HORIZONTAL_SPACING; + const startX = -totalWidth / 2; + + nodesAtLevel.forEach((nodeName, index) => { + positions.set(nodeName, { + x: startX + index * HORIZONTAL_SPACING, + y: level * VERTICAL_SPACING + }); + }); + }); + + return positions; +} + +/** + * Assign nodes to hierarchical levels using BFS + */ +function assignNodesToLevels( + entryPoint: string, + adjacencyList: Map, + allNodes: GraphNodeConfig[] +): Map { + const levels = new Map(); + const visited = new Set(); + const queue: Array<{ node: string; level: number }> = [{ node: entryPoint, level: 0 }]; + + while (queue.length > 0) { + const { node, level } = queue.shift()!; + + if (visited.has(node)) { + continue; + } + visited.add(node); + + if (!levels.has(level)) { + levels.set(level, []); + } + levels.get(level)!.push(node); + + const neighbors = adjacencyList.get(node) || []; + neighbors.forEach(neighbor => { + if (!visited.has(neighbor)) { + queue.push({ node: neighbor, level: level + 1 }); + } + }); + } + + return levels; +} + +/** + * Map internal node types to XYFlow custom node types + */ +function getXYFlowNodeType(type: string): string { + switch (type) { + case 'agent': + return 'agentNode'; + case 'toolExecutor': + return 'toolNode'; + case 'final': + return 'finalNode'; + default: + return 'default'; + } +} + +/** + * Format node name for display + */ +function formatNodeLabel(name: string): string { + // Convert snake_case or camelCase to Title Case + return name + .replace(/([A-Z])/g, ' $1') + .replace(/_/g, ' ') + .replace(/^\w/, c => c.toUpperCase()) + .trim(); +} + +/** + * Get node description based on type + */ +function getNodeDescription(node: GraphNodeConfig): string { + switch (node.type) { + case 'agent': + return 'AI Agent Node'; + case 'toolExecutor': + return 'Tool Executor Node'; + case 'final': + return 'Final Output Node'; + default: + return ''; + } +} + +/** + * Get custom styling for node based on type + */ +function getNodeStyle(type: string): Record { + const baseStyle = { + borderRadius: '8px', + padding: '12px', + border: '2px solid', + fontSize: '13px', + fontWeight: '500' + }; + + switch (type) { + case 'agent': + return { + ...baseStyle, + background: '#e0f2fe', + borderColor: '#0284c7', + color: '#0c4a6e' + }; + case 'toolExecutor': + return { + ...baseStyle, + background: '#fef3c7', + borderColor: '#f59e0b', + color: '#78350f' + }; + case 'final': + return { + ...baseStyle, + background: '#dcfce7', + borderColor: '#16a34a', + color: '#14532d' + }; + default: + return baseStyle; + } +} + +/** + * Get edge styling based on type and condition + */ +function getEdgeStyle(edge: GraphEdgeConfig, condition: string): Record { + // Conditional edges (non-default conditions) get dashed styling + if (condition !== 'default' && edge.conditionType !== 'alwaysAgent') { + return { + stroke: '#9ca3af', + strokeWidth: '2', + strokeDasharray: '5,5' + }; + } + return { + stroke: '#6b7280', + strokeWidth: '2' + }; +} diff --git a/front_end/panels/ai_chat/core/__tests__/GraphConverter.test.ts b/front_end/panels/ai_chat/core/__tests__/GraphConverter.test.ts new file mode 100644 index 0000000000..980e268117 --- /dev/null +++ b/front_end/panels/ai_chat/core/__tests__/GraphConverter.test.ts @@ -0,0 +1,341 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import { convertGraphConfigToXYFlow } from '../GraphConverter.js'; +import type { GraphConfig } from '../ConfigurableGraph.js'; +import { NodeType } from '../Types.js'; + +describe('GraphConverter', () => { + describe('convertGraphConfigToXYFlow', () => { + it('should convert simple 3-node linear graph', () => { + const config: GraphConfig = { + name: 'simple-graph', + entryPoint: 'agent', + nodes: [ + { name: 'agent', type: 'agent' }, + { name: 'toolExecutor', type: 'toolExecutor' }, + { name: 'final', type: 'final' }, + ], + edges: [ + { + source: 'agent', + conditionType: 'routeBasedOnLastMessage', + targetMap: { + agent: 'agent', + toolExecutor: 'toolExecutor', + final: 'final', + }, + }, + { + source: 'toolExecutor', + conditionType: 'alwaysAgent', + targetMap: { + agent: 'agent', + }, + }, + ], + }; + + const result = convertGraphConfigToXYFlow(config); + + // Verify nodes + assert.strictEqual(result.nodes.length, 3); + assert.isTrue(result.nodes.some(n => n.id === 'agent')); + assert.isTrue(result.nodes.some(n => n.id === 'toolExecutor')); + assert.isTrue(result.nodes.some(n => n.id === 'final')); + + // Verify node structure + const agentNode = result.nodes.find(n => n.id === 'agent'); + assert.isDefined(agentNode); + assert.strictEqual(agentNode!.type, 'default'); + assert.isDefined(agentNode!.position); + assert.isDefined(agentNode!.position.x); + assert.isDefined(agentNode!.position.y); + assert.isDefined(agentNode!.data); + assert.strictEqual(agentNode!.data.nodeType, 'agent'); + + // Verify edges - should flatten targetMap into multiple edges + assert.isAtLeast(result.edges.length, 4); // 3 from first edge + 1 from second + + // Check agent -> toolExecutor edge exists + const agentToToolEdge = result.edges.find(e => + e.source === 'agent' && e.target === 'toolExecutor' + ); + assert.isDefined(agentToToolEdge); + assert.strictEqual(agentToToolEdge!.label, 'toolExecutor'); + + // Check toolExecutor -> agent edge exists + const toolToAgentEdge = result.edges.find(e => + e.source === 'toolExecutor' && e.target === 'agent' + ); + assert.isDefined(toolToAgentEdge); + }); + + it('should convert defaultAgentGraphConfig correctly', () => { + const config: GraphConfig = { + name: 'defaultAgentGraph', + entryPoint: NodeType.AGENT.toString(), + nodes: [ + { name: NodeType.AGENT.toString(), type: 'agent' }, + { name: NodeType.TOOL_EXECUTOR.toString(), type: 'toolExecutor' }, + { name: NodeType.FINAL.toString(), type: 'final' }, + ], + edges: [ + { + source: NodeType.AGENT.toString(), + conditionType: 'routeOrPrepareToolExecutor', + targetMap: { + [NodeType.AGENT.toString()]: NodeType.AGENT.toString(), + [NodeType.TOOL_EXECUTOR.toString()]: NodeType.TOOL_EXECUTOR.toString(), + [NodeType.FINAL.toString()]: NodeType.FINAL.toString(), + __end__: '__end__', + }, + }, + { + source: NodeType.TOOL_EXECUTOR.toString(), + conditionType: 'alwaysAgent', + targetMap: { + [NodeType.AGENT.toString()]: NodeType.AGENT.toString(), + }, + }, + ], + }; + + const result = convertGraphConfigToXYFlow(config); + + assert.strictEqual(result.nodes.length, 3); + + // Verify edges don't include __end__ target + assert.isFalse(result.edges.some(e => e.target === '__end__')); + + // Should have 4 edges from first targetMap + 1 from second = 5 total + // (excluding __end__) + assert.strictEqual(result.edges.length, 4); + }); + + it('should handle empty graph', () => { + const config: GraphConfig = { + name: 'empty', + entryPoint: 'start', + nodes: [], + edges: [], + }; + + const result = convertGraphConfigToXYFlow(config); + + assert.strictEqual(result.nodes.length, 0); + assert.strictEqual(result.edges.length, 0); + }); + + it('should handle single node graph', () => { + const config: GraphConfig = { + name: 'single', + entryPoint: 'lonely', + nodes: [{ name: 'lonely', type: 'agent' }], + edges: [], + }; + + const result = convertGraphConfigToXYFlow(config); + + assert.strictEqual(result.nodes.length, 1); + assert.strictEqual(result.nodes[0].id, 'lonely'); + assert.strictEqual(result.edges.length, 0); + }); + + it('should assign hierarchical positions', () => { + const config: GraphConfig = { + name: 'hierarchy', + entryPoint: 'root', + nodes: [ + { name: 'root', type: 'agent' }, + { name: 'child1', type: 'toolExecutor' }, + { name: 'child2', type: 'final' }, + ], + edges: [ + { + source: 'root', + conditionType: 'routeBasedOnLastMessage', + targetMap: { + child1: 'child1', + child2: 'child2', + }, + }, + ], + }; + + const result = convertGraphConfigToXYFlow(config); + + const rootNode = result.nodes.find(n => n.id === 'root'); + const child1Node = result.nodes.find(n => n.id === 'child1'); + const child2Node = result.nodes.find(n => n.id === 'child2'); + + assert.isDefined(rootNode); + assert.isDefined(child1Node); + assert.isDefined(child2Node); + + // Root should be at higher level (lower y) than children + assert.isBelow(rootNode!.position.y, child1Node!.position.y); + assert.isBelow(rootNode!.position.y, child2Node!.position.y); + + // Children should be at same level + assert.strictEqual(child1Node!.position.y, child2Node!.position.y); + }); + + it('should apply correct node styling based on type', () => { + const config: GraphConfig = { + name: 'styled', + entryPoint: 'agent', + nodes: [ + { name: 'agent', type: 'agent' }, + { name: 'tool', type: 'toolExecutor' }, + { name: 'end', type: 'final' }, + ], + edges: [], + }; + + const result = convertGraphConfigToXYFlow(config); + + const agentNode = result.nodes.find(n => n.id === 'agent'); + const toolNode = result.nodes.find(n => n.id === 'tool'); + const finalNode = result.nodes.find(n => n.id === 'end'); + + // Verify styling exists (exact colors tested elsewhere) + assert.isDefined(agentNode!.style); + assert.isDefined(toolNode!.style); + assert.isDefined(finalNode!.style); + + // Different node types should have different styles + assert.notDeepEqual(agentNode!.style, toolNode!.style); + assert.notDeepEqual(agentNode!.style, finalNode!.style); + }); + + it('should format node labels correctly', () => { + const config: GraphConfig = { + name: 'labels', + entryPoint: 'camelCaseNode', + nodes: [ + { name: 'camelCaseNode', type: 'agent' }, + { name: 'snake_case_node', type: 'toolExecutor' }, + { name: 'UPPERCASE', type: 'final' }, + ], + edges: [], + }; + + const result = convertGraphConfigToXYFlow(config); + + const camelNode = result.nodes.find(n => n.id === 'camelCaseNode'); + const snakeNode = result.nodes.find(n => n.id === 'snake_case_node'); + const upperNode = result.nodes.find(n => n.id === 'UPPERCASE'); + + // Labels should be formatted (capitalized, spaces added, etc) + assert.isDefined(camelNode!.data.label); + assert.isDefined(snakeNode!.data.label); + assert.isDefined(upperNode!.data.label); + + // Labels should not be empty + assert.isAbove(camelNode!.data.label.length, 0); + assert.isAbove(snakeNode!.data.label.length, 0); + assert.isAbove(upperNode!.data.label.length, 0); + }); + + it('should handle conditional edges with proper labels', () => { + const config: GraphConfig = { + name: 'conditional', + entryPoint: 'start', + nodes: [ + { name: 'start', type: 'agent' }, + { name: 'path1', type: 'toolExecutor' }, + { name: 'path2', type: 'final' }, + ], + edges: [ + { + source: 'start', + conditionType: 'routeBasedOnLastMessage', + targetMap: { + condition_a: 'path1', + condition_b: 'path2', + }, + }, + ], + }; + + const result = convertGraphConfigToXYFlow(config); + + // Find edges with labels + const edgeWithLabel1 = result.edges.find(e => e.source === 'start' && e.target === 'path1'); + const edgeWithLabel2 = result.edges.find(e => e.source === 'start' && e.target === 'path2'); + + assert.isDefined(edgeWithLabel1); + assert.isDefined(edgeWithLabel2); + + // Conditional edges should have labels + assert.strictEqual(edgeWithLabel1!.label, 'condition_a'); + assert.strictEqual(edgeWithLabel2!.label, 'condition_b'); + + // Conditional edges should use smoothstep type + assert.strictEqual(edgeWithLabel1!.type, 'smoothstep'); + assert.strictEqual(edgeWithLabel2!.type, 'smoothstep'); + }); + + it('should handle alwaysAgent edge type correctly', () => { + const config: GraphConfig = { + name: 'always', + entryPoint: 'tool', + nodes: [ + { name: 'tool', type: 'toolExecutor' }, + { name: 'agent', type: 'agent' }, + ], + edges: [ + { + source: 'tool', + conditionType: 'alwaysAgent', + targetMap: { + agent: 'agent', + }, + }, + ], + }; + + const result = convertGraphConfigToXYFlow(config); + + const edge = result.edges.find(e => e.source === 'tool'); + + assert.isDefined(edge); + // alwaysAgent edges should use default type + assert.strictEqual(edge!.type, 'default'); + assert.isFalse(edge!.animated); + }); + + it('should exclude __end__ targets from edges', () => { + const config: GraphConfig = { + name: 'with-end', + entryPoint: 'start', + nodes: [ + { name: 'start', type: 'agent' }, + { name: 'finish', type: 'final' }, + ], + edges: [ + { + source: 'start', + conditionType: 'routeBasedOnLastMessage', + targetMap: { + continue: 'finish', + __end__: '__end__', + }, + }, + ], + }; + + const result = convertGraphConfigToXYFlow(config); + + // Should not have edge targeting __end__ + const endEdge = result.edges.find(e => e.target === '__end__'); + assert.isUndefined(endEdge); + + // Should have edge to 'finish' + const finishEdge = result.edges.find(e => e.target === 'finish'); + assert.isDefined(finishEdge); + }); + }); +}); diff --git a/front_end/panels/ai_chat/tools/__tests__/GetWebAppDataTool.test.ts b/front_end/panels/ai_chat/tools/__tests__/GetWebAppDataTool.test.ts new file mode 100644 index 0000000000..f962b7df87 --- /dev/null +++ b/front_end/panels/ai_chat/tools/__tests__/GetWebAppDataTool.test.ts @@ -0,0 +1,424 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import {GetWebAppDataTool, type GetWebAppDataResult} from '../GetWebAppDataTool.js'; +import type {ErrorResult} from '../Tools.js'; +import * as SDK from '../../../../core/sdk/sdk.js'; + +describe('GetWebAppDataTool', () => { + let targetManager: SDK.TargetManager.TargetManager; + let mockTarget: any; + let mockRuntimeAgent: any; + + beforeEach(() => { + targetManager = SDK.TargetManager.TargetManager.instance(); + + // Create mock runtime agent + mockRuntimeAgent = { + invoke_evaluate: sinon.stub(), + }; + + // Create mock target + mockTarget = { + runtimeAgent: () => mockRuntimeAgent, + }; + + // Stub target manager to return our mock target + sinon.stub(targetManager, 'primaryPageTarget').returns(mockTarget); + }); + + afterEach(() => { + sinon.restore(); + }); + + function assertSuccess(result: GetWebAppDataResult | ErrorResult): asserts result is GetWebAppDataResult { + assert.strictEqual('success' in result, true); + if ('success' in result) { + assert.strictEqual(result.success, true); + } + } + + function assertError(result: GetWebAppDataResult | ErrorResult): asserts result is ErrorResult { + assert.strictEqual('error' in result, true); + } + + describe('checkbox aggregation', () => { + it('should return empty array for unchecked checkbox group', async () => { + // Simulate multiple unchecked checkboxes with same name + mockRuntimeAgent.invoke_evaluate.resolves({ + result: { + value: { + success: true, + formData: { + colors: [], // No checkboxes checked + }, + message: 'Webapp data retrieved successfully', + }, + }, + }); + + const tool = new GetWebAppDataTool(); + const result = await tool.execute({ + webappId: 'test-webapp', + reasoning: 'Testing unchecked checkboxes', + }); + + assertSuccess(result); + assert.deepEqual(result.formData.colors, []); + }); + + it('should return array with only checked values for checkbox group', async () => { + // Simulate multiple checkboxes with some checked + mockRuntimeAgent.invoke_evaluate.resolves({ + result: { + value: { + success: true, + formData: { + colors: ['blue', 'green'], // Only checked values + }, + message: 'Webapp data retrieved successfully', + }, + }, + }); + + const tool = new GetWebAppDataTool(); + const result = await tool.execute({ + webappId: 'test-webapp', + reasoning: 'Testing mixed checkboxes', + }); + + assertSuccess(result); + assert.isArray(result.formData.colors); + assert.deepEqual(result.formData.colors, ['blue', 'green']); + // Should NOT contain false values + assert.notInclude(result.formData.colors, false); + }); + + it('should return single value for single checked checkbox', async () => { + // Single checkbox checked + mockRuntimeAgent.invoke_evaluate.resolves({ + result: { + value: { + success: true, + formData: { + subscribe: true, + }, + message: 'Webapp data retrieved successfully', + }, + }, + }); + + const tool = new GetWebAppDataTool(); + const result = await tool.execute({ + webappId: 'test-webapp', + reasoning: 'Testing single checkbox', + }); + + assertSuccess(result); + assert.strictEqual(result.formData.subscribe, true); + }); + + it('should return empty array for single unchecked checkbox', async () => { + // Single checkbox unchecked + mockRuntimeAgent.invoke_evaluate.resolves({ + result: { + value: { + success: true, + formData: { + subscribe: [], + }, + message: 'Webapp data retrieved successfully', + }, + }, + }); + + const tool = new GetWebAppDataTool(); + const result = await tool.execute({ + webappId: 'test-webapp', + reasoning: 'Testing single unchecked checkbox', + }); + + assertSuccess(result); + assert.deepEqual(result.formData.subscribe, []); + }); + + it('should handle checkbox with custom values', async () => { + // Checkboxes with value attributes + mockRuntimeAgent.invoke_evaluate.resolves({ + result: { + value: { + success: true, + formData: { + options: ['option1', 'option3'], + }, + message: 'Webapp data retrieved successfully', + }, + }, + }); + + const tool = new GetWebAppDataTool(); + const result = await tool.execute({ + webappId: 'test-webapp', + reasoning: 'Testing checkbox values', + }); + + assertSuccess(result); + assert.deepEqual(result.formData.options, ['option1', 'option3']); + }); + }); + + describe('other form elements', () => { + it('should extract text input values', async () => { + mockRuntimeAgent.invoke_evaluate.resolves({ + result: { + value: { + success: true, + formData: { + name: 'John Doe', + email: 'john@example.com', + }, + message: 'Webapp data retrieved successfully', + }, + }, + }); + + const tool = new GetWebAppDataTool(); + const result = await tool.execute({ + webappId: 'test-webapp', + reasoning: 'Testing text inputs', + }); + + assertSuccess(result); + assert.strictEqual(result.formData.name, 'John Doe'); + assert.strictEqual(result.formData.email, 'john@example.com'); + }); + + it('should extract radio button values', async () => { + mockRuntimeAgent.invoke_evaluate.resolves({ + result: { + value: { + success: true, + formData: { + gender: 'male', + }, + message: 'Webapp data retrieved successfully', + }, + }, + }); + + const tool = new GetWebAppDataTool(); + const result = await tool.execute({ + webappId: 'test-webapp', + reasoning: 'Testing radio buttons', + }); + + assertSuccess(result); + assert.strictEqual(result.formData.gender, 'male'); + }); + + it('should extract select values', async () => { + mockRuntimeAgent.invoke_evaluate.resolves({ + result: { + value: { + success: true, + formData: { + country: 'USA', + }, + message: 'Webapp data retrieved successfully', + }, + }, + }); + + const tool = new GetWebAppDataTool(); + const result = await tool.execute({ + webappId: 'test-webapp', + reasoning: 'Testing select', + }); + + assertSuccess(result); + assert.strictEqual(result.formData.country, 'USA'); + }); + + it('should extract multiple select values as array', async () => { + mockRuntimeAgent.invoke_evaluate.resolves({ + result: { + value: { + success: true, + formData: { + languages: ['en', 'es', 'fr'], + }, + message: 'Webapp data retrieved successfully', + }, + }, + }); + + const tool = new GetWebAppDataTool(); + const result = await tool.execute({ + webappId: 'test-webapp', + reasoning: 'Testing multiple select', + }); + + assertSuccess(result); + assert.isArray(result.formData.languages); + assert.deepEqual(result.formData.languages, ['en', 'es', 'fr']); + }); + + it('should extract textarea values', async () => { + mockRuntimeAgent.invoke_evaluate.resolves({ + result: { + value: { + success: true, + formData: { + comments: 'This is a comment', + }, + message: 'Webapp data retrieved successfully', + }, + }, + }); + + const tool = new GetWebAppDataTool(); + const result = await tool.execute({ + webappId: 'test-webapp', + reasoning: 'Testing textarea', + }); + + assertSuccess(result); + assert.strictEqual(result.formData.comments, 'This is a comment'); + }); + }); + + describe('error handling', () => { + it('should return error when no target available', async () => { + sinon.restore(); + sinon.stub(targetManager, 'primaryPageTarget').returns(null); + + const tool = new GetWebAppDataTool(); + const result = await tool.execute({ + webappId: 'test-webapp', + reasoning: 'Testing no target', + }); + + assertError(result); + assert.include(result.error, 'No page target available'); + }); + + it('should return error when webappId is missing', async () => { + const tool = new GetWebAppDataTool(); + const result = await tool.execute({ + webappId: '', + reasoning: 'Testing', + } as any); + + assertError(result); + assert.include(result.error, 'webappId is required'); + }); + + it('should return error when reasoning is missing', async () => { + const tool = new GetWebAppDataTool(); + const result = await tool.execute({ + webappId: 'test-webapp', + reasoning: '', + } as any); + + assertError(result); + assert.include(result.error, 'Reasoning is required'); + }); + + it('should return error when webapp iframe not found', async () => { + mockRuntimeAgent.invoke_evaluate.resolves({ + result: { + value: { + success: false, + error: 'Webapp iframe not found with ID: test-webapp', + }, + }, + }); + + const tool = new GetWebAppDataTool(); + const result = await tool.execute({ + webappId: 'test-webapp', + reasoning: 'Testing missing iframe', + }); + + assertError(result); + assert.include(result.error, 'Webapp iframe not found'); + }); + + it('should return error on evaluation exception', async () => { + mockRuntimeAgent.invoke_evaluate.resolves({ + result: {value: null}, + exceptionDetails: { + text: 'Script execution error', + }, + }); + + const tool = new GetWebAppDataTool(); + const result = await tool.execute({ + webappId: 'test-webapp', + reasoning: 'Testing exception', + }); + + assertError(result); + assert.include(result.error, 'Script execution error'); + }); + }); + + describe('waitForSubmit', () => { + it('should wait for form submission when waitForSubmit is true', async () => { + // First call - not submitted + // Second call - submitted + mockRuntimeAgent.invoke_evaluate + .onFirstCall().resolves({ + result: { + value: {found: true, submitted: false}, + }, + }) + .onSecondCall().resolves({ + result: { + value: {found: true, submitted: true}, + }, + }) + .onThirdCall().resolves({ + result: { + value: { + success: true, + formData: {name: 'Test'}, + message: 'Webapp data retrieved successfully', + }, + }, + }); + + const tool = new GetWebAppDataTool(); + const result = await tool.execute({ + webappId: 'test-webapp', + reasoning: 'Testing wait for submit', + waitForSubmit: true, + timeout: 2000, + }); + + assertSuccess(result); + assert.strictEqual(result.formData.name, 'Test'); + }); + + it('should timeout when form not submitted within timeout', async () => { + // Always return not submitted + mockRuntimeAgent.invoke_evaluate.resolves({ + result: { + value: {found: true, submitted: false}, + }, + }); + + const tool = new GetWebAppDataTool(); + const result = await tool.execute({ + webappId: 'test-webapp', + reasoning: 'Testing timeout', + waitForSubmit: true, + timeout: 1000, + }); + + assertError(result); + assert.include(result.error, 'Timeout waiting for webapp form submission'); + }); + }); +}); diff --git a/front_end/panels/ai_chat/ui/ChatView.ts b/front_end/panels/ai_chat/ui/ChatView.ts index fa0ceb0bb4..a4d03a4d62 100644 --- a/front_end/panels/ai_chat/ui/ChatView.ts +++ b/front_end/panels/ai_chat/ui/ChatView.ts @@ -30,6 +30,8 @@ import './input/InputBar.js'; import './model_selector/ModelSelector.js'; import { combineMessages } from './message/MessageCombiner.js'; import { StructuredResponseController } from './message/StructuredResponseController.js'; +import { WorkflowVisualizer } from './WorkflowVisualizer.js'; +import { defaultAgentGraphConfig } from '../core/GraphConfigs.js'; // Shared chat types import type { ChatMessage, ModelChatMessage, ToolResultMessage, AgentSessionMessage, ImageInputData } from '../models/ChatTypes.js'; @@ -806,7 +808,24 @@ export class ChatView extends HTMLElement { }) : Lit.nothing} ${this.#renderInputBar(false)} - + + + ${this.#messages.length > 0 ? html` + + ` : Lit.nothing} `, this.#shadow, {host: this}); } @@ -1229,6 +1248,37 @@ export class ChatView extends HTMLElement { } } + /** + * Handle View Workflow button click + */ + async #handleViewWorkflow(): Promise { + try { + logger.info('Opening workflow visualization'); + + // Use defaultAgentGraphConfig for now + // TODO: Get current graph config from agent service if available + const graphConfig = defaultAgentGraphConfig; + + // Show visualization + const result = await WorkflowVisualizer.show(graphConfig, { + readonly: true, + showMiniMap: true, + showControls: true, + fitView: true + }); + + if (!result.success) { + logger.error('Failed to show workflow visualization', result.error); + } else { + logger.info('Workflow visualization opened', { + webappId: result.webappId + }); + } + } catch (error) { + logger.error('Error opening workflow visualization', error); + } + } + // Stable key for message list rendering to avoid node reuse glitches #messageKey(m: CombinedMessage, index: number): string { diff --git a/front_end/panels/ai_chat/ui/WorkflowVisualizer.ts b/front_end/panels/ai_chat/ui/WorkflowVisualizer.ts new file mode 100644 index 0000000000..92e9733754 --- /dev/null +++ b/front_end/panels/ai_chat/ui/WorkflowVisualizer.ts @@ -0,0 +1,681 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import { RenderWebAppTool } from '../tools/RenderWebAppTool.js'; +import { convertGraphConfigToXYFlow } from '../core/GraphConverter.js'; +import type { GraphConfig } from '../core/ConfigurableGraph.js'; +import { createLogger } from '../core/Logger.js'; + +const logger = createLogger('WorkflowVisualizer'); + +export interface WorkflowVisualizerOptions { + readonly?: boolean; + showMiniMap?: boolean; + showControls?: boolean; + fitView?: boolean; +} + +/** + * WorkflowVisualizer - Renders interactive workflow graphs using XYFlow + * + * Uses RenderWebAppTool to create an isolated iframe with React + XYFlow + * loaded from CDN for zero build system dependencies. + */ +export class WorkflowVisualizer { + /** + * Display workflow graph in full-screen visualization + */ + static async show( + graphConfig: GraphConfig, + options: WorkflowVisualizerOptions = {} + ): Promise<{ success: boolean; webappId?: string; error?: string }> { + try { + logger.info('Rendering workflow visualization', { + nodeCount: graphConfig.nodes.length, + edgeCount: graphConfig.edges.length, + options + }); + + // Convert graph config to XYFlow format + const xyflowData = convertGraphConfigToXYFlow(graphConfig); + + // Build visualization HTML/CSS/JS + const html = this.buildHTML(options); + const css = this.buildCSS(); + const js = this.buildJS(xyflowData, options); + + // Render using existing tool + const tool = new RenderWebAppTool(); + const result = await tool.execute({ + html, + css, + js, + reasoning: `Visualize workflow graph: ${graphConfig.name || 'Unnamed Graph'}` + }); + + if ('error' in result) { + logger.error('Failed to render workflow visualization', result.error); + return { success: false, error: result.error }; + } + + logger.info('Successfully rendered workflow visualization', { + webappId: result.webappId + }); + + return { + success: true, + webappId: result.webappId + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + logger.error('Error rendering workflow visualization', errorMsg); + return { success: false, error: errorMsg }; + } + } + + /** + * Build HTML structure for visualization + */ + private static buildHTML(_options: WorkflowVisualizerOptions): string { + return ` + +
+
+
+ + + + + + + +

Workflow Graph

+
+
+ + +
+
+
+ + +
+ + +
+
+

Loading visualization...

+
+ + +`; + } + + /** + * Build CSS styles for visualization + */ + private static buildCSS(): string { + return ` +/* ======================================== + WORKFLOW VISUALIZER STYLES + ======================================== */ + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', sans-serif; + background: #f9fafb; + color: #111827; + overflow: hidden; +} + +/* Header */ +.visualizer-header { + position: fixed; + top: 0; + left: 0; + right: 0; + height: 60px; + background: white; + border-bottom: 1px solid #e5e7eb; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + z-index: 1000; +} + +.header-content { + display: flex; + justify-content: space-between; + align-items: center; + height: 100%; + padding: 0 24px; +} + +.header-title { + display: flex; + align-items: center; + gap: 12px; +} + +.header-icon { + width: 24px; + height: 24px; + color: #00a4fe; +} + +.header-title h1 { + font-size: 18px; + font-weight: 600; + color: #111827; +} + +.header-actions { + display: flex; + gap: 8px; +} + +.action-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + background: white; + border: 1px solid #d1d5db; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + color: #374151; + cursor: pointer; + transition: all 0.2s; +} + +.action-btn:hover { + background: #f9fafb; + border-color: #9ca3af; +} + +.action-btn svg { + width: 16px; + height: 16px; +} + +.close-btn { + background: #ef4444; + color: white; + border-color: #ef4444; +} + +.close-btn:hover { + background: #dc2626; + border-color: #dc2626; +} + +/* Loading Indicator */ +.loading-indicator { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + text-align: center; + z-index: 2000; +} + +.loading-indicator.hidden { + display: none; +} + +.spinner { + width: 48px; + height: 48px; + border: 4px solid #e5e7eb; + border-top-color: #00a4fe; + border-radius: 50%; + animation: spin 1s linear infinite; + margin: 0 auto 16px; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.loading-indicator p { + font-size: 14px; + color: #6b7280; +} + +/* Node Details Panel */ +.node-details-panel { + position: fixed; + top: 80px; + right: 20px; + width: 320px; + background: white; + border: 1px solid #e5e7eb; + border-radius: 8px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + z-index: 1001; + transition: transform 0.3s ease; +} + +.node-details-panel.hidden { + transform: translateX(400px); +} + +.panel-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px; + border-bottom: 1px solid #e5e7eb; + background: #f9fafb; + border-radius: 8px 8px 0 0; +} + +.panel-header h3 { + font-size: 16px; + font-weight: 600; + color: #111827; +} + +.close-panel-btn { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + font-size: 24px; + color: #6b7280; + cursor: pointer; + border-radius: 4px; + transition: all 0.2s; +} + +.close-panel-btn:hover { + background: #e5e7eb; + color: #111827; +} + +.panel-content { + padding: 16px; +} + +.detail-row { + margin-bottom: 12px; +} + +.detail-row:last-child { + margin-bottom: 0; +} + +.detail-label { + display: block; + font-size: 12px; + font-weight: 600; + color: #6b7280; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 4px; +} + +.detail-value { + display: block; + font-size: 14px; + color: #111827; +} + +/* React Flow Overrides */ +.react-flow { + background: #fafafa; +} + +.react-flow__node { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif; +} + +.react-flow__edge-path { + stroke-width: 2px; +} + +.react-flow__handle { + width: 8px; + height: 8px; + background: #6b7280; + border: 2px solid white; +} + +/* Custom Node Styles */ +.react-flow__node-agentNode { + background: linear-gradient(135deg, #e0f2fe 0%, #bae6fd 100%); + border: 2px solid #0284c7; + border-radius: 8px; + padding: 12px 16px; + box-shadow: 0 2px 4px rgba(2, 132, 199, 0.2); +} + +.react-flow__node-toolNode { + background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%); + border: 2px solid #f59e0b; + border-radius: 8px; + padding: 12px 16px; + box-shadow: 0 2px 4px rgba(245, 158, 11, 0.2); +} + +.react-flow__node-finalNode { + background: linear-gradient(135deg, #dcfce7 0%, #bbf7d0 100%); + border: 2px solid #16a34a; + border-radius: 8px; + padding: 12px 16px; + box-shadow: 0 2px 4px rgba(22, 163, 74, 0.2); +} + +/* Responsive */ +@media (max-width: 768px) { + .header-content { + padding: 0 16px; + } + + .header-title h1 { + font-size: 16px; + } + + .action-btn { + padding: 6px 12px; + font-size: 13px; + } + + .node-details-panel { + width: calc(100vw - 40px); + right: 20px; + } +}`; + } + + /** + * Build JavaScript for XYFlow initialization and interaction + */ + private static buildJS( + xyflowData: { nodes: any[]; edges: any[] }, + options: WorkflowVisualizerOptions + ): string { + const defaultOptions = { + readonly: true, + showMiniMap: true, + showControls: true, + fitView: true, + ...options + }; + + return ` +(function() { + 'use strict'; + + console.log('[WorkflowVisualizer] Initializing...'); + + // Configuration + const GRAPH_DATA = ${JSON.stringify(xyflowData)}; + const OPTIONS = ${JSON.stringify(defaultOptions)}; + + // CDN URLs + const CDN_URLS = { + react: 'https://unpkg.com/react@18.2.0/umd/react.production.min.js', + reactDOM: 'https://unpkg.com/react-dom@18.2.0/umd/react-dom.production.min.js', + xyflow: 'https://cdn.jsdelivr.net/npm/@xyflow/react@12.8.6/dist/umd/index.js', + xyflowCSS: 'https://cdn.jsdelivr.net/npm/@xyflow/react@12.8.6/dist/style.css' + }; + + /** + * Load script from CDN + */ + function loadScript(url) { + return new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = url; + script.crossOrigin = 'anonymous'; + script.onload = () => { + console.log('[WorkflowVisualizer] Loaded:', url); + resolve(); + }; + script.onerror = () => { + console.error('[WorkflowVisualizer] Failed to load:', url); + reject(new Error('Failed to load script: ' + url)); + }; + document.head.appendChild(script); + }); + } + + /** + * Load CSS from CDN + */ + function loadCSS(url) { + return new Promise((resolve, reject) => { + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = url; + link.crossOrigin = 'anonymous'; + link.onload = () => { + console.log('[WorkflowVisualizer] Loaded CSS:', url); + resolve(); + }; + link.onerror = () => { + console.error('[WorkflowVisualizer] Failed to load CSS:', url); + reject(new Error('Failed to load CSS: ' + url)); + }; + document.head.appendChild(link); + }); + } + + /** + * Initialize React Flow after dependencies loaded + */ + function initializeReactFlow() { + console.log('[WorkflowVisualizer] Initializing React Flow...'); + + const { createElement: h, useState, useCallback } = window.React; + const { createRoot } = window.ReactDOM; + const { ReactFlow, Background, Controls, MiniMap } = window.ReactFlow; + + // React Flow Component + function WorkflowGraph() { + const [nodes, setNodes] = useState(GRAPH_DATA.nodes); + const [edges, setEdges] = useState(GRAPH_DATA.edges); + const [selectedNode, setSelectedNode] = useState(null); + + // Handle node click + const onNodeClick = useCallback((event, node) => { + console.log('[WorkflowVisualizer] Node clicked:', node.id); + setSelectedNode(node); + showNodeDetails(node); + }, []); + + // Handle pane click (deselect) + const onPaneClick = useCallback(() => { + setSelectedNode(null); + hideNodeDetails(); + }, []); + + return h(ReactFlow, { + nodes: nodes, + edges: edges, + onNodeClick: onNodeClick, + onPaneClick: onPaneClick, + fitView: OPTIONS.fitView, + attributionPosition: 'bottom-left', + defaultViewport: { x: 0, y: 0, zoom: 1 }, + minZoom: 0.1, + maxZoom: 2, + nodesDraggable: !OPTIONS.readonly, + nodesConnectable: !OPTIONS.readonly, + elementsSelectable: true + }, + OPTIONS.showControls ? h(Controls) : null, + OPTIONS.showMiniMap ? h(MiniMap, { + nodeColor: (node) => { + switch (node.type) { + case 'agentNode': return '#0284c7'; + case 'toolNode': return '#f59e0b'; + case 'finalNode': return '#16a34a'; + default: return '#6b7280'; + } + }, + maskColor: 'rgba(0, 0, 0, 0.1)' + }) : null, + h(Background, { color: '#e5e7eb', gap: 16 }) + ); + } + + // Render + const root = createRoot(document.getElementById('react-flow-wrapper')); + root.render(h(WorkflowGraph)); + + // Hide loading indicator + document.getElementById('loading-indicator').classList.add('hidden'); + + console.log('[WorkflowVisualizer] React Flow initialized successfully'); + } + + /** + * Show node details panel + */ + function showNodeDetails(node) { + const panel = document.getElementById('node-details-panel'); + const nodeName = document.getElementById('node-name'); + const nodeType = document.getElementById('node-type'); + const nodeDescription = document.getElementById('node-description'); + + nodeName.textContent = node.data.label || node.id; + nodeType.textContent = node.data.nodeType || 'Unknown'; + nodeDescription.textContent = node.data.description || 'No description available'; + + panel.classList.remove('hidden'); + + // Store selected node for external access + document.body.setAttribute('data-selected-node-id', node.id); + document.body.setAttribute('data-selected-node-data', JSON.stringify(node.data)); + } + + /** + * Hide node details panel + */ + function hideNodeDetails() { + const panel = document.getElementById('node-details-panel'); + panel.classList.add('hidden'); + + document.body.removeAttribute('data-selected-node-id'); + document.body.removeAttribute('data-selected-node-data'); + } + + /** + * Setup event listeners + */ + function setupEventListeners() { + // Close button + document.getElementById('close-btn').addEventListener('click', () => { + console.log('[WorkflowVisualizer] Closing visualization'); + const iframe = window.frameElement; + if (iframe && iframe.parentNode) { + iframe.parentNode.removeChild(iframe); + } + }); + + // Reset view button + document.getElementById('reset-view-btn').addEventListener('click', () => { + console.log('[WorkflowVisualizer] Resetting view'); + window.location.reload(); + }); + + // Close panel button + document.getElementById('close-panel-btn').addEventListener('click', () => { + hideNodeDetails(); + }); + } + + /** + * Main initialization + */ + async function initialize() { + try { + console.log('[WorkflowVisualizer] Loading dependencies from CDN...'); + + // Load dependencies in sequence + await loadCSS(CDN_URLS.xyflowCSS); + await loadScript(CDN_URLS.react); + await loadScript(CDN_URLS.reactDOM); + await loadScript(CDN_URLS.xyflow); + + // Check if libraries loaded + if (!window.React || !window.ReactDOM || !window.ReactFlow) { + throw new Error('Required libraries not loaded. Check: ' + + JSON.stringify({ + React: !!window.React, + ReactDOM: !!window.ReactDOM, + ReactFlow: !!window.ReactFlow + }) + ); + } + + console.log('[WorkflowVisualizer] All dependencies loaded successfully'); + + // Setup UI + setupEventListeners(); + + // Initialize React Flow + initializeReactFlow(); + + } catch (error) { + console.error('[WorkflowVisualizer] Initialization failed:', error); + + // Show error to user + const loadingIndicator = document.getElementById('loading-indicator'); + loadingIndicator.innerHTML = + '

Failed to load visualization

' + + '

' + error.message + '

' + + ''; + } + } + + // Start initialization + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initialize); + } else { + initialize(); + } +})();`; + } +} diff --git a/front_end/panels/ai_chat/ui/__tests__/ChatViewWorkflowButton.test.ts b/front_end/panels/ai_chat/ui/__tests__/ChatViewWorkflowButton.test.ts new file mode 100644 index 0000000000..e31a4fde04 --- /dev/null +++ b/front_end/panels/ai_chat/ui/__tests__/ChatViewWorkflowButton.test.ts @@ -0,0 +1,408 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import '../ChatView.js'; +import { raf } from '../../../../testing/DOMHelpers.js'; +import { WorkflowVisualizer } from '../WorkflowVisualizer.js'; + +// Local enums/types to avoid TS enum imports in strip mode +const ChatMessageEntity = { + USER: 'user', + MODEL: 'model', + AGENT_SESSION: 'agent_session', + TOOL_RESULT: 'tool_result', +} as const; + +function makeUserMessage(text: string): any { + return { entity: ChatMessageEntity.USER, text } as any; +} + +function makeModelMessage(text: string): any { + return { entity: ChatMessageEntity.MODEL, text } as any; +} + +describe('ChatView Workflow Button', () => { + let view: any; + let workflowVisualizerStub: sinon.SinonStub; + + beforeEach(() => { + // Stub WorkflowVisualizer.show + workflowVisualizerStub = sinon.stub(WorkflowVisualizer, 'show'); + workflowVisualizerStub.resolves({ + success: true, + webappId: 'test-webapp-123', + }); + + // Create ChatView element + view = document.createElement('devtools-chat-view'); + document.body.appendChild(view); + }); + + afterEach(() => { + if (view && view.parentNode) { + document.body.removeChild(view); + } + sinon.restore(); + }); + + function queryWorkflowButton(viewElement: HTMLElement): HTMLElement | null { + const shadow = viewElement.shadowRoot; + if (!shadow) { + return null; + } + return shadow.querySelector('.workflow-fab-button') as HTMLElement | null; + } + + describe('button visibility', () => { + it('should not show workflow button when no messages', async () => { + view.data = { + messages: [], + state: 'idle', + isTextInputEmpty: true, + onSendMessage: () => {}, + onPromptSelected: () => {}, + }; + await raf(); + + const button = queryWorkflowButton(view); + assert.isNull(button); + }); + + it('should show workflow button when messages exist', async () => { + view.data = { + messages: [makeUserMessage('Hello'), makeModelMessage('Hi there!')], + state: 'idle', + isTextInputEmpty: true, + onSendMessage: () => {}, + onPromptSelected: () => {}, + }; + await raf(); + + const button = queryWorkflowButton(view); + assert.isNotNull(button); + }); + + it('should show workflow button with single message', async () => { + view.data = { + messages: [makeUserMessage('Test')], + state: 'idle', + isTextInputEmpty: true, + onSendMessage: () => {}, + onPromptSelected: () => {}, + }; + await raf(); + + const button = queryWorkflowButton(view); + assert.isNotNull(button); + }); + + it('should hide workflow button when messages are cleared', async () => { + // Start with messages + view.data = { + messages: [makeUserMessage('Test')], + state: 'idle', + isTextInputEmpty: true, + onSendMessage: () => {}, + onPromptSelected: () => {}, + }; + await raf(); + + let button = queryWorkflowButton(view); + assert.isNotNull(button, 'Button should exist with messages'); + + // Clear messages + view.data = { + messages: [], + state: 'idle', + isTextInputEmpty: true, + onSendMessage: () => {}, + onPromptSelected: () => {}, + }; + await raf(); + + button = queryWorkflowButton(view); + assert.isNull(button, 'Button should not exist without messages'); + }); + }); + + describe('button interaction', () => { + it('should call WorkflowVisualizer.show when clicked', async () => { + view.data = { + messages: [makeUserMessage('Test')], + state: 'idle', + isTextInputEmpty: true, + onSendMessage: () => {}, + onPromptSelected: () => {}, + }; + await raf(); + + const button = queryWorkflowButton(view); + assert.isNotNull(button); + + button!.click(); + await raf(); + + assert.isTrue(workflowVisualizerStub.calledOnce); + }); + + it('should pass graph config to WorkflowVisualizer', async () => { + view.data = { + messages: [makeUserMessage('Test')], + state: 'idle', + isTextInputEmpty: true, + onSendMessage: () => {}, + onPromptSelected: () => {}, + }; + await raf(); + + const button = queryWorkflowButton(view); + button!.click(); + await raf(); + + const callArgs = workflowVisualizerStub.firstCall.args; + assert.isDefined(callArgs[0], 'Should pass graph config'); + assert.isDefined(callArgs[0].name); + assert.isDefined(callArgs[0].nodes); + assert.isDefined(callArgs[0].edges); + }); + + it('should pass options to WorkflowVisualizer', async () => { + view.data = { + messages: [makeUserMessage('Test')], + state: 'idle', + isTextInputEmpty: true, + onSendMessage: () => {}, + onPromptSelected: () => {}, + }; + await raf(); + + const button = queryWorkflowButton(view); + button!.click(); + await raf(); + + const callArgs = workflowVisualizerStub.firstCall.args; + assert.isDefined(callArgs[1], 'Should pass options'); + + const options = callArgs[1]; + assert.isTrue(options.readonly); + assert.isTrue(options.showMiniMap); + assert.isTrue(options.showControls); + assert.isTrue(options.fitView); + }); + + it('should handle multiple clicks gracefully', async () => { + view.data = { + messages: [makeUserMessage('Test')], + state: 'idle', + isTextInputEmpty: true, + onSendMessage: () => {}, + onPromptSelected: () => {}, + }; + await raf(); + + const button = queryWorkflowButton(view); + + button!.click(); + button!.click(); + button!.click(); + await raf(); + + // Should have been called 3 times + assert.strictEqual(workflowVisualizerStub.callCount, 3); + }); + + it('should handle visualization errors gracefully', async () => { + workflowVisualizerStub.resolves({ + success: false, + error: 'Failed to render', + }); + + view.data = { + messages: [makeUserMessage('Test')], + state: 'idle', + isTextInputEmpty: true, + onSendMessage: () => {}, + onPromptSelected: () => {}, + }; + await raf(); + + const button = queryWorkflowButton(view); + + // Should not throw + button!.click(); + await raf(); + + assert.isTrue(workflowVisualizerStub.calledOnce); + }); + + it('should handle visualization exceptions gracefully', async () => { + workflowVisualizerStub.rejects(new Error('Network error')); + + view.data = { + messages: [makeUserMessage('Test')], + state: 'idle', + isTextInputEmpty: true, + onSendMessage: () => {}, + onPromptSelected: () => {}, + }; + await raf(); + + const button = queryWorkflowButton(view); + + // Should not throw + button!.click(); + await raf(); + + assert.isTrue(workflowVisualizerStub.calledOnce); + }); + }); + + describe('button styling', () => { + it('should have fixed positioning', async () => { + view.data = { + messages: [makeUserMessage('Test')], + state: 'idle', + isTextInputEmpty: true, + onSendMessage: () => {}, + onPromptSelected: () => {}, + }; + await raf(); + + const button = queryWorkflowButton(view); + assert.isNotNull(button); + + const computedStyle = window.getComputedStyle(button!); + assert.strictEqual(computedStyle.position, 'fixed'); + }); + + it('should be positioned in bottom-right corner', async () => { + view.data = { + messages: [makeUserMessage('Test')], + state: 'idle', + isTextInputEmpty: true, + onSendMessage: () => {}, + onPromptSelected: () => {}, + }; + await raf(); + + const button = queryWorkflowButton(view); + assert.isNotNull(button); + + const computedStyle = window.getComputedStyle(button!); + assert.strictEqual(computedStyle.bottom, '90px'); + assert.strictEqual(computedStyle.right, '20px'); + }); + + it('should have circular shape', async () => { + view.data = { + messages: [makeUserMessage('Test')], + state: 'idle', + isTextInputEmpty: true, + onSendMessage: () => {}, + onPromptSelected: () => {}, + }; + await raf(); + + const button = queryWorkflowButton(view); + assert.isNotNull(button); + + const computedStyle = window.getComputedStyle(button!); + assert.strictEqual(computedStyle.borderRadius, '50%'); + }); + + it('should have workflow graph icon', async () => { + view.data = { + messages: [makeUserMessage('Test')], + state: 'idle', + isTextInputEmpty: true, + onSendMessage: () => {}, + onPromptSelected: () => {}, + }; + await raf(); + + const button = queryWorkflowButton(view); + assert.isNotNull(button); + + // Should contain SVG icon + const svg = button!.querySelector('svg'); + assert.isNotNull(svg); + + // Should have graph-like icon (circles and lines) + const circles = svg!.querySelectorAll('circle'); + const lines = svg!.querySelectorAll('line'); + assert.isAtLeast(circles.length, 2, 'Should have circles for nodes'); + assert.isAtLeast(lines.length, 1, 'Should have lines for edges'); + }); + + it('should have accessible title and aria-label', async () => { + view.data = { + messages: [makeUserMessage('Test')], + state: 'idle', + isTextInputEmpty: true, + onSendMessage: () => {}, + onPromptSelected: () => {}, + }; + await raf(); + + const button = queryWorkflowButton(view); + assert.isNotNull(button); + + const title = button!.getAttribute('title'); + const ariaLabel = button!.getAttribute('aria-label'); + + assert.isDefined(title); + assert.isDefined(ariaLabel); + assert.include(title!.toLowerCase(), 'workflow'); + assert.include(ariaLabel!.toLowerCase(), 'workflow'); + }); + }); + + describe('button behavior with different message types', () => { + it('should show button with user messages only', async () => { + view.data = { + messages: [makeUserMessage('Test1'), makeUserMessage('Test2')], + state: 'idle', + isTextInputEmpty: true, + onSendMessage: () => {}, + onPromptSelected: () => {}, + }; + await raf(); + + const button = queryWorkflowButton(view); + assert.isNotNull(button); + }); + + it('should show button with model messages only', async () => { + view.data = { + messages: [makeModelMessage('Response1'), makeModelMessage('Response2')], + state: 'idle', + isTextInputEmpty: true, + onSendMessage: () => {}, + onPromptSelected: () => {}, + }; + await raf(); + + const button = queryWorkflowButton(view); + assert.isNotNull(button); + }); + + it('should show button with mixed message types', async () => { + view.data = { + messages: [ + makeUserMessage('Question'), + makeModelMessage('Answer'), + makeUserMessage('Follow-up'), + ], + state: 'idle', + isTextInputEmpty: true, + onSendMessage: () => {}, + onPromptSelected: () => {}, + }; + await raf(); + + const button = queryWorkflowButton(view); + assert.isNotNull(button); + }); + }); +}); diff --git a/front_end/panels/ai_chat/ui/__tests__/WorkflowVisualizer.test.ts b/front_end/panels/ai_chat/ui/__tests__/WorkflowVisualizer.test.ts new file mode 100644 index 0000000000..723de36a98 --- /dev/null +++ b/front_end/panels/ai_chat/ui/__tests__/WorkflowVisualizer.test.ts @@ -0,0 +1,318 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import { WorkflowVisualizer } from '../WorkflowVisualizer.js'; +import type { GraphConfig } from '../../core/ConfigurableGraph.js'; +import { RenderWebAppTool } from '../../tools/RenderWebAppTool.js'; +import type { RenderWebAppResult } from '../../tools/RenderWebAppTool.js'; + +describe('WorkflowVisualizer', () => { + let renderWebAppToolStub: sinon.SinonStub; + let mockGraphConfig: GraphConfig; + + beforeEach(() => { + // Create mock GraphConfig + mockGraphConfig = { + name: 'test-graph', + entryPoint: 'agent', + nodes: [ + { name: 'agent', type: 'agent' }, + { name: 'toolExecutor', type: 'toolExecutor' }, + { name: 'final', type: 'final' }, + ], + edges: [ + { + source: 'agent', + conditionType: 'routeBasedOnLastMessage', + targetMap: { + agent: 'agent', + toolExecutor: 'toolExecutor', + final: 'final', + }, + }, + ], + }; + + // Stub RenderWebAppTool.prototype.execute + renderWebAppToolStub = sinon.stub(RenderWebAppTool.prototype, 'execute'); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('show', () => { + it('should call RenderWebAppTool with correct structure', async () => { + renderWebAppToolStub.resolves({ + success: true, + webappId: 'test-webapp-123', + message: 'Webapp rendered successfully', + } as RenderWebAppResult); + + await WorkflowVisualizer.show(mockGraphConfig); + + assert.isTrue(renderWebAppToolStub.calledOnce); + + const callArgs = renderWebAppToolStub.firstCall.args[0]; + assert.isDefined(callArgs.html); + assert.isDefined(callArgs.css); + assert.isDefined(callArgs.js); + assert.isDefined(callArgs.reasoning); + }); + + it('should include CDN script tags for React and XYFlow in JS', async () => { + renderWebAppToolStub.resolves({ + success: true, + webappId: 'test-webapp-123', + message: 'Webapp rendered successfully', + } as RenderWebAppResult); + + await WorkflowVisualizer.show(mockGraphConfig); + + const callArgs = renderWebAppToolStub.firstCall.args[0]; + const jsCode = callArgs.js; + + // Should include CDN URLs + assert.include(jsCode, 'unpkg.com/react@18.2.0'); + assert.include(jsCode, 'unpkg.com/react-dom@18.2.0'); + assert.include(jsCode, '@xyflow/react'); + + // Should reference window.ReactFlow global + assert.include(jsCode, 'window.ReactFlow'); + }); + + it('should include XYFlow CSS in the CSS parameter', async () => { + renderWebAppToolStub.resolves({ + success: true, + webappId: 'test-webapp-123', + message: 'Webapp rendered successfully', + } as RenderWebAppResult); + + await WorkflowVisualizer.show(mockGraphConfig); + + const callArgs = renderWebAppToolStub.firstCall.args[0]; + const cssCode = callArgs.css; + + // Should include XYFlow CSS URL or import + assert.include(cssCode, '@xyflow/react'); + }); + + it('should return success result from RenderWebAppTool', async () => { + const expectedResult: RenderWebAppResult = { + success: true, + webappId: 'test-webapp-456', + message: 'Webapp rendered successfully', + }; + + renderWebAppToolStub.resolves(expectedResult); + + const result = await WorkflowVisualizer.show(mockGraphConfig); + + assert.isTrue(result.success); + assert.strictEqual(result.webappId, 'test-webapp-456'); + }); + + it('should handle RenderWebAppTool errors', async () => { + renderWebAppToolStub.resolves({ + error: 'Failed to render webapp', + }); + + const result = await WorkflowVisualizer.show(mockGraphConfig); + + assert.isFalse(result.success); + assert.isDefined(result.error); + }); + + it('should apply readonly option when specified', async () => { + renderWebAppToolStub.resolves({ + success: true, + webappId: 'test-webapp-123', + message: 'Webapp rendered successfully', + } as RenderWebAppResult); + + await WorkflowVisualizer.show(mockGraphConfig, { readonly: true }); + + const callArgs = renderWebAppToolStub.firstCall.args[0]; + const jsCode = callArgs.js; + + // Should include readonly configuration in JS + assert.include(jsCode, 'nodesDraggable'); + assert.include(jsCode, 'nodesConnectable'); + assert.include(jsCode, 'elementsSelectable'); + }); + + it('should apply showMiniMap option when specified', async () => { + renderWebAppToolStub.resolves({ + success: true, + webappId: 'test-webapp-123', + message: 'Webapp rendered successfully', + } as RenderWebAppResult); + + await WorkflowVisualizer.show(mockGraphConfig, { showMiniMap: true }); + + const callArgs = renderWebAppToolStub.firstCall.args[0]; + const jsCode = callArgs.js; + + // Should render MiniMap component + assert.include(jsCode, 'MiniMap'); + }); + + it('should apply showControls option when specified', async () => { + renderWebAppToolStub.resolves({ + success: true, + webappId: 'test-webapp-123', + message: 'Webapp rendered successfully', + } as RenderWebAppResult); + + await WorkflowVisualizer.show(mockGraphConfig, { showControls: true }); + + const callArgs = renderWebAppToolStub.firstCall.args[0]; + const jsCode = callArgs.js; + + // Should render Controls component + assert.include(jsCode, 'Controls'); + }); + + it('should apply fitView option when specified', async () => { + renderWebAppToolStub.resolves({ + success: true, + webappId: 'test-webapp-123', + message: 'Webapp rendered successfully', + } as RenderWebAppResult); + + await WorkflowVisualizer.show(mockGraphConfig, { fitView: true }); + + const callArgs = renderWebAppToolStub.firstCall.args[0]; + const jsCode = callArgs.js; + + // Should include fitView option + assert.include(jsCode, 'fitView'); + }); + + it('should apply all options together', async () => { + renderWebAppToolStub.resolves({ + success: true, + webappId: 'test-webapp-123', + message: 'Webapp rendered successfully', + } as RenderWebAppResult); + + await WorkflowVisualizer.show(mockGraphConfig, { + readonly: true, + showMiniMap: true, + showControls: true, + fitView: true, + }); + + const callArgs = renderWebAppToolStub.firstCall.args[0]; + const jsCode = callArgs.js; + + assert.include(jsCode, 'nodesDraggable'); + assert.include(jsCode, 'MiniMap'); + assert.include(jsCode, 'Controls'); + assert.include(jsCode, 'fitView'); + }); + + it('should create root div for React rendering in HTML', async () => { + renderWebAppToolStub.resolves({ + success: true, + webappId: 'test-webapp-123', + message: 'Webapp rendered successfully', + } as RenderWebAppResult); + + await WorkflowVisualizer.show(mockGraphConfig); + + const callArgs = renderWebAppToolStub.firstCall.args[0]; + const htmlCode = callArgs.html; + + // Should have root div for React + assert.include(htmlCode, 'id="root"'); + }); + + it('should include reasoning in the call', async () => { + renderWebAppToolStub.resolves({ + success: true, + webappId: 'test-webapp-123', + message: 'Webapp rendered successfully', + } as RenderWebAppResult); + + await WorkflowVisualizer.show(mockGraphConfig); + + const callArgs = renderWebAppToolStub.firstCall.args[0]; + + assert.isDefined(callArgs.reasoning); + assert.isString(callArgs.reasoning); + assert.isAbove(callArgs.reasoning.length, 0); + }); + + it('should handle empty graph config', async () => { + const emptyConfig: GraphConfig = { + name: 'empty', + entryPoint: 'none', + nodes: [], + edges: [], + }; + + renderWebAppToolStub.resolves({ + success: true, + webappId: 'test-webapp-123', + message: 'Webapp rendered successfully', + } as RenderWebAppResult); + + const result = await WorkflowVisualizer.show(emptyConfig); + + assert.isTrue(result.success); + assert.isTrue(renderWebAppToolStub.calledOnce); + }); + + it('should convert graph to XYFlow format before rendering', async () => { + renderWebAppToolStub.resolves({ + success: true, + webappId: 'test-webapp-123', + message: 'Webapp rendered successfully', + } as RenderWebAppResult); + + await WorkflowVisualizer.show(mockGraphConfig); + + const callArgs = renderWebAppToolStub.firstCall.args[0]; + const jsCode = callArgs.js; + + // Should include nodes and edges data in JS + assert.include(jsCode, 'nodes'); + assert.include(jsCode, 'edges'); + + // Should reference node names from config + assert.include(jsCode, 'agent'); + assert.include(jsCode, 'toolExecutor'); + assert.include(jsCode, 'final'); + }); + + it('should style nodes with primary color', async () => { + renderWebAppToolStub.resolves({ + success: true, + webappId: 'test-webapp-123', + message: 'Webapp rendered successfully', + } as RenderWebAppResult); + + await WorkflowVisualizer.show(mockGraphConfig); + + const callArgs = renderWebAppToolStub.firstCall.args[0]; + const cssCode = callArgs.css; + + // Should include primary color styling + assert.include(cssCode, '#00a4fe'); + }); + + it('should handle exceptions during visualization', async () => { + renderWebAppToolStub.rejects(new Error('Network error')); + + try { + await WorkflowVisualizer.show(mockGraphConfig); + assert.fail('Should have thrown error'); + } catch (error) { + assert.instanceOf(error, Error); + assert.include((error as Error).message, 'Network error'); + } + }); + }); +}); diff --git a/front_end/panels/ai_chat/ui/chatView.css b/front_end/panels/ai_chat/ui/chatView.css index 71d96e4354..46826ee5ef 100644 --- a/front_end/panels/ai_chat/ui/chatView.css +++ b/front_end/panels/ai_chat/ui/chatView.css @@ -3137,3 +3137,56 @@ devtools-snackbar.bookmark-notification .container { .version-banner-dismiss:hover { opacity: 1; } + +/* ======================================== + WORKFLOW VISUALIZATION FAB BUTTON + ======================================== */ + +.workflow-fab-button { + position: fixed; + bottom: 90px; + right: 20px; + width: 56px; + height: 56px; + background: var(--primary-color, #00a4fe); + color: white; + border: none; + border-radius: 50%; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1), 0 2px 4px rgba(0, 0, 0, 0.06); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + z-index: 999; +} + +.workflow-fab-button:hover { + background: var(--primary-color-hover, #0094e8); + transform: scale(1.05); + box-shadow: 0 6px 8px rgba(0, 0, 0, 0.15), 0 3px 6px rgba(0, 0, 0, 0.1); +} + +.workflow-fab-button:active { + transform: scale(0.95); +} + +.workflow-fab-button svg { + width: 28px; + height: 28px; +} + +/* Responsive */ +@media (max-width: 768px) { + .workflow-fab-button { + bottom: 80px; + right: 16px; + width: 48px; + height: 48px; + } + + .workflow-fab-button svg { + width: 24px; + height: 24px; + } +}