diff --git a/apps/agent/entrypoints/options/create-graph/GraphCanvas.tsx b/apps/agent/entrypoints/options/create-graph/GraphCanvas.tsx index 408929bfc..63b85cb89 100644 --- a/apps/agent/entrypoints/options/create-graph/GraphCanvas.tsx +++ b/apps/agent/entrypoints/options/create-graph/GraphCanvas.tsx @@ -1,19 +1,8 @@ -import { - Background, - BackgroundVariant, - ControlButton, - Controls, - type Edge, - MiniMap, - type Node, - ReactFlow, - ReactFlowProvider, - useEdgesState, - useNodesState, - useReactFlow, -} from '@xyflow/react' -import '@xyflow/react/dist/style.css' -import dagre from 'dagre' +import cytoscape from 'cytoscape' +import dagre from 'cytoscape-dagre' +// @ts-expect-error no types available +import nodeHtmlLabel from 'cytoscape-node-html-label' +import DOMPurify from 'dompurify' import { ArrowLeft, Maximize, @@ -24,7 +13,7 @@ import { Save, } from 'lucide-react' import type { FC } from 'react' -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { useNavigate } from 'react-router' import useDeepCompareEffect from 'use-deep-compare-effect' import ProductLogo from '@/assets/product_logo.svg' @@ -35,19 +24,75 @@ import { TooltipTrigger, } from '@/components/ui/tooltip' import type { GraphData } from './CreateGraph' -import { CustomNode, type NodeType } from './CustomNode' +import type { NodeType } from './CustomNode' -const nodeTypes: Record = { - start: CustomNode, - end: CustomNode, - nav: CustomNode, - act: CustomNode, - extract: CustomNode, - verify: CustomNode, - decision: CustomNode, - loop: CustomNode, - fork: CustomNode, - join: CustomNode, +cytoscape.use(dagre) +nodeHtmlLabel(cytoscape) + +const NODE_CONFIG: Record< + NodeType, + { color: string; bgColor: string; icon: string; label: string } +> = { + start: { + color: '#22c55e', + bgColor: 'rgba(34, 197, 94, 0.1)', + icon: ``, + label: 'START', + }, + end: { + color: '#ef4444', + bgColor: 'rgba(239, 68, 68, 0.1)', + icon: ``, + label: 'END', + }, + nav: { + color: '#3b82f6', + bgColor: 'rgba(59, 130, 246, 0.1)', + icon: ``, + label: 'NAVIGATE', + }, + act: { + color: '#8b5cf6', + bgColor: 'rgba(139, 92, 246, 0.1)', + icon: ``, + label: 'ACTION', + }, + extract: { + color: '#f59e0b', + bgColor: 'rgba(245, 158, 11, 0.1)', + icon: ``, + label: 'EXTRACT', + }, + verify: { + color: '#10b981', + bgColor: 'rgba(16, 185, 129, 0.1)', + icon: ``, + label: 'VERIFY', + }, + decision: { + color: '#ec4899', + bgColor: 'rgba(236, 72, 153, 0.1)', + icon: ``, + label: 'DECISION', + }, + loop: { + color: '#06b6d4', + bgColor: 'rgba(6, 182, 212, 0.1)', + icon: ``, + label: 'LOOP', + }, + fork: { + color: '#6366f1', + bgColor: 'rgba(99, 102, 241, 0.1)', + icon: ``, + label: 'FORK', + }, + join: { + color: '#84cc16', + bgColor: 'rgba(132, 204, 22, 0.1)', + icon: ``, + label: 'JOIN', + }, } const initialData: GraphData = { @@ -61,38 +106,69 @@ const initialData: GraphData = { edges: [], } -const dagreGraph = new dagre.graphlib.Graph() -dagreGraph.setDefaultEdgeLabel(() => ({})) +const MIN_NODE_WIDTH = 180 +const MAX_NODE_WIDTH = 240 +const BASE_NODE_HEIGHT = 70 +const CHAR_WIDTH = 7 +const ICON_AND_PADDING = 62 +const MAX_ZOOM = 1.2 -const nodeWidth = 180 -const nodeHeight = 60 +const calculateNodeDimensions = ( + label: string, +): { width: number; height: number } => { + const textWidth = label.length * CHAR_WIDTH + ICON_AND_PADDING + const width = Math.max(MIN_NODE_WIDTH, Math.min(MAX_NODE_WIDTH, textWidth)) -const getLayoutedElements = (nodes: Node[], edges: Edge[]) => { - dagreGraph.setGraph({ rankdir: 'TB', nodesep: 80, ranksep: 100 }) + const maxCharsPerLine = Math.floor((width - ICON_AND_PADDING) / CHAR_WIDTH) + const lines = Math.ceil(label.length / maxCharsPerLine) + const extraHeight = Math.max(0, lines - 1) * 18 + const height = BASE_NODE_HEIGHT + extraHeight - nodes.forEach((node) => { - dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight }) - }) + return { width, height } +} - edges.forEach((edge) => { - dagreGraph.setEdge(edge.source, edge.target) - }) - - dagre.layout(dagreGraph) - - nodes.forEach((node) => { - const nodeWithPosition = dagreGraph.node(node.id) - node.position = { - x: nodeWithPosition.x - nodeWidth / 2, - y: nodeWithPosition.y - nodeHeight / 2, - } - node.style = { - ...node.style, - transition: 'transform 0.3s ease-in-out', - } - }) - - return { nodes, edges } +const createNodeHtml = (type: NodeType, label: string): string => { + const config = NODE_CONFIG[type] || NODE_CONFIG.start + const sanitizedLabel = DOMPurify.sanitize(label, { ALLOWED_TAGS: [] }) + return ` +
+
+ ${config.icon} +
+
+
${config.label}
+
${sanitizedLabel}
+
+
+ ` } type GraphCanvasProps = { @@ -108,7 +184,7 @@ type GraphCanvasProps = { panelSize?: { asPercentage: number; inPixels: number } } -const GraphCanvasInner: FC = ({ +export const GraphCanvas: FC = ({ graphName, onGraphNameChange, graphData = initialData, @@ -121,8 +197,9 @@ const GraphCanvasInner: FC = ({ panelSize, }) => { const [isEditingName, setIsEditingName] = useState(false) - const { fitView, zoomIn, zoomOut } = useReactFlow() const navigate = useNavigate() + const containerRef = useRef(null) + const cyRef = useRef(null) const handleBack = () => { if (shouldBlockNavigation) { @@ -153,51 +230,158 @@ const GraphCanvasInner: FC = ({ return isSaved ? 'Save Changes' : 'Save Workflow' } - // Initialize nodes and edges with layout - const initialLayout = getLayoutedElements( - graphData.nodes.map((n) => ({ - ...n, - data: { ...n.data, type: n.type }, - position: { x: 0, y: 0 }, - })), - graphData.edges, - ) + const zoomIn = useCallback(() => { + cyRef.current?.zoom(cyRef.current.zoom() * 1.2) + cyRef.current?.center() + }, []) - const [nodes, setNodes, onNodesChange] = useNodesState(initialLayout.nodes) - const [edges, setEdges, onEdgesChange] = useEdgesState(initialLayout.edges) + const zoomOut = useCallback(() => { + cyRef.current?.zoom(cyRef.current.zoom() / 1.2) + cyRef.current?.center() + }, []) - // Handle graph updates from chat - const handleGraphUpdate = useCallback( - // biome-ignore lint/suspicious/noExplicitAny: graph data from external source - (newGraphData: { nodes: any[]; edges: any[] }) => { - const layouted = getLayoutedElements( - newGraphData.nodes.map((n) => ({ - ...n, - data: { ...n.data, type: n.type }, - position: { x: 0, y: 0 }, - })), - newGraphData.edges, - ) - setNodes(layouted.nodes) - setEdges(layouted.edges) - }, - [setNodes, setEdges], - ) + const fitView = useCallback(() => { + cyRef.current?.fit(undefined, 50) + cyRef.current?.center() + }, []) + + useEffect(() => { + if (!containerRef.current) return + + const cy = cytoscape({ + container: containerRef.current, + elements: [], + style: [ + { + selector: 'node', + style: { + width: 'data(nodeWidth)', + height: 'data(nodeHeight)', + 'background-opacity': 0, + 'border-width': 0, + }, + }, + { + selector: 'edge', + style: { + width: 2, + 'line-color': '#f97316', + 'target-arrow-color': '#f97316', + 'target-arrow-shape': 'triangle', + 'curve-style': 'bezier', + 'arrow-scale': 1.2, + }, + }, + { + selector: 'edge.back-edge', + style: { + 'line-style': 'dashed', + 'line-dash-pattern': [6, 3], + 'curve-style': 'unbundled-bezier', + 'control-point-distances': [100], + 'control-point-weights': [0.5], + }, + }, + ], + layout: { name: 'preset' }, + userZoomingEnabled: true, + userPanningEnabled: true, + boxSelectionEnabled: false, + selectionType: 'single', + autoungrabify: true, + autounselectify: true, + maxZoom: MAX_ZOOM, + minZoom: 0.2, + }) + + // @ts-expect-error nodeHtmlLabel extension + cy.nodeHtmlLabel([ + { + query: 'node', + halign: 'center', + valign: 'center', + halignBox: 'center', + valignBox: 'center', + tpl: (data: { type: NodeType; label: string }) => { + return createNodeHtml(data.type, data.label) + }, + }, + ]) + + cyRef.current = cy + + return () => { + cy.destroy() + } + }, []) + + const updateGraph = useCallback((data: GraphData) => { + const cy = cyRef.current + if (!cy) return + + cy.elements().remove() + + const nodes = data.nodes.map((node) => { + const dimensions = calculateNodeDimensions(node.data.label) + return { + data: { + id: node.id, + label: node.data.label, + type: node.type as NodeType, + nodeWidth: dimensions.width, + nodeHeight: dimensions.height, + }, + } + }) + + const edges = data.edges.map((edge) => ({ + data: { + id: edge.id, + source: edge.source, + target: edge.target, + }, + })) + + cy.add([...nodes, ...edges]) + + cy.layout({ + name: 'dagre', + rankDir: 'TB', + nodeSep: 80, + rankSep: 100, + padding: 50, + animate: true, + animationDuration: 300, + fit: true, + } as cytoscape.LayoutOptions).run() + + setTimeout(() => { + cy.edges().forEach((edge) => { + const sourceNode = edge.source() + const targetNode = edge.target() + const sourceY = sourceNode.position('y') + const targetY = targetNode.position('y') + + if (sourceY > targetY) { + edge.addClass('back-edge') + } + }) + }, 350) + }, []) useDeepCompareEffect(() => { - handleGraphUpdate(graphData) - setTimeout(() => fitView({ duration: 300, maxZoom: 0.75 }), 50) + updateGraph(graphData) }, [graphData]) - // Auto fitView when panel is resized useEffect(() => { if (panelSize?.inPixels !== undefined) { - fitView({ duration: 200, maxZoom: 0.75 }) + cyRef.current?.resize() + setTimeout(() => fitView(), 100) } }, [panelSize?.inPixels, fitView]) return ( -
+
{/* Graph Header */}
@@ -283,93 +467,48 @@ const GraphCanvasInner: FC = ({
{/* Graph Canvas */} -
- +
- - + + {/* Zoom Controls */} +
+ + + +
) } - -export const GraphCanvas: FC = (props) => { - return ( - - - - ) -} diff --git a/apps/agent/package.json b/apps/agent/package.json index 4460006b7..8e6418de6 100644 --- a/apps/agent/package.json +++ b/apps/agent/package.json @@ -40,6 +40,8 @@ "@radix-ui/react-use-controllable-state": "^1.2.2", "@sentry/react": "^10.31.0", "@sentry/vite-plugin": "^4.6.1", + "@types/cytoscape": "^3.31.0", + "@types/dompurify": "^3.2.0", "@webext-core/messaging": "^2.3.0", "@wxt-dev/storage": "^1.2.6", "@xyflow/react": "^12.9.3", @@ -47,8 +49,11 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", - "dagre": "^0.8.5", + "cytoscape": "^3.33.1", + "cytoscape-dagre": "^2.5.0", + "cytoscape-node-html-label": "^1.2.2", "dayjs": "^1.11.19", + "dompurify": "^3.3.1", "downshift": "^9.0.10", "embla-carousel-react": "^8.6.0", "es-toolkit": "^1.42.0", @@ -82,6 +87,7 @@ "@tailwindcss/vite": "^4.1.17", "@types/bun": "^1.3.5", "@types/chrome": "^0.1.28", + "@types/cytoscape-dagre": "^2.3.4", "@types/dagre": "^0.7.53", "@types/react": "^19.1.12", "@types/react-dom": "^19.1.9", diff --git a/bun.lock b/bun.lock index 2281bd611..fa81ab7b8 100644 --- a/bun.lock +++ b/bun.lock @@ -44,6 +44,8 @@ "@radix-ui/react-use-controllable-state": "^1.2.2", "@sentry/react": "^10.31.0", "@sentry/vite-plugin": "^4.6.1", + "@types/cytoscape": "^3.31.0", + "@types/dompurify": "^3.2.0", "@webext-core/messaging": "^2.3.0", "@wxt-dev/storage": "^1.2.6", "@xyflow/react": "^12.9.3", @@ -51,8 +53,11 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", - "dagre": "^0.8.5", + "cytoscape": "^3.33.1", + "cytoscape-dagre": "^2.5.0", + "cytoscape-node-html-label": "^1.2.2", "dayjs": "^1.11.19", + "dompurify": "^3.3.1", "downshift": "^9.0.10", "embla-carousel-react": "^8.6.0", "es-toolkit": "^1.42.0", @@ -86,6 +91,7 @@ "@tailwindcss/vite": "^4.1.17", "@types/bun": "^1.3.5", "@types/chrome": "^0.1.28", + "@types/cytoscape-dagre": "^2.3.4", "@types/dagre": "^0.7.53", "@types/react": "^19.1.12", "@types/react-dom": "^19.1.9", @@ -117,7 +123,7 @@ }, "apps/server": { "name": "@browseros/server", - "version": "0.0.40", + "version": "0.0.41", "bin": { "browseros-server": "./src/index.ts", }, @@ -1115,6 +1121,10 @@ "@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="], + "@types/cytoscape": ["@types/cytoscape@3.31.0", "", { "dependencies": { "cytoscape": "*" } }, "sha512-EXHOHxqQjGxLDEh5cP4te6J0bi7LbCzmZkzsR6f703igUac8UGMdEohMyU3GHAayCTZrLQOMnaE/lqB2Ekh8Ww=="], + + "@types/cytoscape-dagre": ["@types/cytoscape-dagre@2.3.4", "", { "dependencies": { "cytoscape": "^3.31" } }, "sha512-uOGXuPfPLFoKZaegjHl9oj4tqONNJuhUl180FiJgRZ35rVijBs6J4UP1Ah6mA6S46h+7pv4ICqpgfdC3EADZlw=="], + "@types/d3": ["@types/d3@7.4.3", "", { "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", "@types/d3-brush": "*", "@types/d3-chord": "*", "@types/d3-color": "*", "@types/d3-contour": "*", "@types/d3-delaunay": "*", "@types/d3-dispatch": "*", "@types/d3-drag": "*", "@types/d3-dsv": "*", "@types/d3-ease": "*", "@types/d3-fetch": "*", "@types/d3-force": "*", "@types/d3-format": "*", "@types/d3-geo": "*", "@types/d3-hierarchy": "*", "@types/d3-interpolate": "*", "@types/d3-path": "*", "@types/d3-polygon": "*", "@types/d3-quadtree": "*", "@types/d3-random": "*", "@types/d3-scale": "*", "@types/d3-scale-chromatic": "*", "@types/d3-selection": "*", "@types/d3-shape": "*", "@types/d3-time": "*", "@types/d3-time-format": "*", "@types/d3-timer": "*", "@types/d3-transition": "*", "@types/d3-zoom": "*" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="], "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], @@ -1181,6 +1191,8 @@ "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], + "@types/dompurify": ["@types/dompurify@3.2.0", "", { "dependencies": { "dompurify": "*" } }, "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg=="], + "@types/eslint": ["@types/eslint@9.6.1", "", { "dependencies": { "@types/estree": "*", "@types/json-schema": "*" } }, "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag=="], "@types/eslint-scope": ["@types/eslint-scope@3.7.7", "", { "dependencies": { "@types/eslint": "*", "@types/estree": "*" } }, "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg=="], @@ -1497,7 +1509,7 @@ "chroma-js": ["chroma-js@3.2.0", "", {}, "sha512-os/OippSlX1RlWWr+QDPcGUZs0uoqr32urfxESG9U93lhUfbnlyckte84Q8P1UQY/qth983AS1JONKmLS4T0nw=="], - "chrome-devtools-mcp": ["chrome-devtools-mcp@0.12.1", "", { "bin": { "chrome-devtools-mcp": "build/src/index.js" } }, "sha512-QREfGxJVVlBrjKdyis9px6UHyXix+Rre9nCkqX7CY7GsU8c6azOwwV8inQB8E3h2/QGqi4sCSF8fmjfAvmE07Q=="], + "chrome-devtools-mcp": ["chrome-devtools-mcp@0.13.0", "", { "bin": { "chrome-devtools-mcp": "build/src/index.js" } }, "sha512-CgotJczVYe6wG2b5cqwNFq7n4VXIM8qfvfutdzVlABPKf0b99b0TDPuRsWrviCthigSHzhpMyFQW03P5Utt1Fg=="], "chrome-launcher": ["chrome-launcher@1.2.0", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^2.0.1" }, "bin": { "print-chrome-path": "bin/print-chrome-path.cjs" } }, "sha512-JbuGuBNss258bvGil7FT4HKdC3SC2K7UAEUqiPy3ACS3Yxo3hAW6bvFpCu2HsIJLgTqxgEX6BkujvzZfLpUD0Q=="], @@ -1605,8 +1617,12 @@ "cytoscape-cose-bilkent": ["cytoscape-cose-bilkent@4.1.0", "", { "dependencies": { "cose-base": "^1.0.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ=="], + "cytoscape-dagre": ["cytoscape-dagre@2.5.0", "", { "dependencies": { "dagre": "^0.8.5" }, "peerDependencies": { "cytoscape": "^3.2.22" } }, "sha512-VG2Knemmshop4kh5fpLO27rYcyUaaDkRw+6PiX4bstpB+QFt0p2oauMrsjVbUamGWQ6YNavh7x2em2uZlzV44g=="], + "cytoscape-fcose": ["cytoscape-fcose@2.2.0", "", { "dependencies": { "cose-base": "^2.2.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ=="], + "cytoscape-node-html-label": ["cytoscape-node-html-label@1.2.2", "", { "peerDependencies": { "@types/cytoscape": "^3.1.0", "cytoscape": "^3.0.0" }, "optionalPeers": ["@types/cytoscape"] }, "sha512-oUVwrlsIlaJJ8QrQFSMdv3uXVXPg6tMH/Tfofr8JuZIovqI4fPqBi6sQgCMcVpS6k9Td0TTjowBsNRw32CESWg=="], + "d3": ["d3@7.9.0", "", { "dependencies": { "d3-array": "3", "d3-axis": "3", "d3-brush": "3", "d3-chord": "3", "d3-color": "3", "d3-contour": "4", "d3-delaunay": "6", "d3-dispatch": "3", "d3-drag": "3", "d3-dsv": "3", "d3-ease": "3", "d3-fetch": "3", "d3-force": "3", "d3-format": "3", "d3-geo": "3", "d3-hierarchy": "3", "d3-interpolate": "3", "d3-path": "3", "d3-polygon": "3", "d3-quadtree": "3", "d3-random": "3", "d3-scale": "4", "d3-scale-chromatic": "3", "d3-selection": "3", "d3-shape": "3", "d3-time": "3", "d3-time-format": "4", "d3-timer": "3", "d3-transition": "3", "d3-zoom": "3" } }, "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA=="], "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],