diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..c5521de --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,28 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run Extension", + "type": "extensionHost", + "request": "launch", + "args": [ + "--extensionDevelopmentPath=${workspaceFolder}/packages/vscode" + ], + "outFiles": [ + "${workspaceFolder}/packages/vscode/dist/**/*.js" + ], + "preLaunchTask": "build: vscode extension" + }, + { + "name": "Run Extension (no build)", + "type": "extensionHost", + "request": "launch", + "args": [ + "--extensionDevelopmentPath=${workspaceFolder}/packages/vscode" + ], + "outFiles": [ + "${workspaceFolder}/packages/vscode/dist/**/*.js" + ] + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..9258e55 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,24 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build: vscode extension", + "type": "shell", + "command": "node", + "args": ["packages/vscode/scripts/build-all.mjs"], + "options": { + "cwd": "${workspaceFolder}" + }, + "problemMatcher": ["$tsc"], + "presentation": { + "reveal": "always", + "panel": "dedicated", + "clear": true + }, + "group": { + "kind": "build", + "isDefault": true + } + } + ] +} diff --git a/apps/ui/package.json b/apps/ui/package.json index 4340a21..979c862 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -19,6 +19,7 @@ "@agentscript/agentforce-dialect": "workspace:*", "@agentscript/agentscript-dialect": "workspace:*", "@agentscript/compiler": "workspace:*", + "@agentscript/graph-ui": "workspace:*", "@agentscript/language": "workspace:*", "@agentscript/lsp": "workspace:*", "@agentscript/lsp-browser": "workspace:*", diff --git a/apps/ui/src/components/explorer/TreeView.tsx b/apps/ui/src/components/explorer/TreeView.tsx index 76873f9..59767e5 100644 --- a/apps/ui/src/components/explorer/TreeView.tsx +++ b/apps/ui/src/components/explorer/TreeView.tsx @@ -7,7 +7,7 @@ import { ChevronRight, Folder, FileCode2 } from 'lucide-react'; import * as React from 'react'; -import { getBlockTypeConfig } from '~/lib/block-type-config'; +import { getBlockTypeConfig } from '@agentscript/graph-ui'; import { cn } from '~/lib/utils'; import { Empty, diff --git a/apps/ui/src/components/graph/ActionDrawerContent.tsx b/apps/ui/src/components/graph/ActionDrawerContent.tsx index 87a1159..112722d 100644 --- a/apps/ui/src/components/graph/ActionDrawerContent.tsx +++ b/apps/ui/src/components/graph/ActionDrawerContent.tsx @@ -12,7 +12,7 @@ */ import { useAppStore } from '~/store'; -import type { ActionDrawerData } from '~/lib/ast-to-graph'; +import type { ActionDrawerData } from '@agentscript/graph-ui'; import { isNamedMap, type Statement } from '@agentscript/language'; import type { AgentScriptAST } from '~/lib/parser'; import { Play, Settings2, Shield, ArrowRight, Equal } from 'lucide-react'; diff --git a/apps/ui/src/components/graph/ConditionalBuilderView.tsx b/apps/ui/src/components/graph/ConditionalBuilderView.tsx index 923c1d4..b50b774 100644 --- a/apps/ui/src/components/graph/ConditionalBuilderView.tsx +++ b/apps/ui/src/components/graph/ConditionalBuilderView.tsx @@ -13,8 +13,10 @@ import { useMemo } from 'react'; import type { Statement } from '@agentscript/language'; import type { AgentScriptAST } from '~/lib/parser'; -import { findTopicBlock } from '~/lib/ast-utils'; -import type { ConditionalEdgeData } from '~/lib/ast-to-graph'; +import { + findTopicBlock, + type ConditionalEdgeData, +} from '@agentscript/graph-ui'; import { useAppStore } from '~/store'; import { IfStatementEditor } from '~/components/builder/statements/IfStatementEditor'; diff --git a/apps/ui/src/components/graph/ConditionalCodeView.tsx b/apps/ui/src/components/graph/ConditionalCodeView.tsx index 314900a..374cbde 100644 --- a/apps/ui/src/components/graph/ConditionalCodeView.tsx +++ b/apps/ui/src/components/graph/ConditionalCodeView.tsx @@ -13,8 +13,10 @@ import { useMemo } from 'react'; import type { Statement } from '@agentscript/language'; import type { AgentScriptAST } from '~/lib/parser'; -import { findTopicBlock } from '~/lib/ast-utils'; -import type { ConditionalEdgeData } from '~/lib/ast-to-graph'; +import { + findTopicBlock, + type ConditionalEdgeData, +} from '@agentscript/graph-ui'; import { useAppStore } from '~/store'; interface ConditionalCodeViewProps { diff --git a/apps/ui/src/components/graph/ConditionalDrawerContent.tsx b/apps/ui/src/components/graph/ConditionalDrawerContent.tsx index 99fb6eb..2d14819 100644 --- a/apps/ui/src/components/graph/ConditionalDrawerContent.tsx +++ b/apps/ui/src/components/graph/ConditionalDrawerContent.tsx @@ -12,7 +12,7 @@ */ import { useState } from 'react'; -import type { ConditionalEdgeData } from '~/lib/ast-to-graph'; +import type { ConditionalEdgeData } from '@agentscript/graph-ui'; import { ConditionalBuilderView } from './ConditionalBuilderView'; import { ConditionalCodeView } from './ConditionalCodeView'; import { cn } from '~/lib/utils'; diff --git a/apps/ui/src/components/graph/NodeDrawerContent.tsx b/apps/ui/src/components/graph/NodeDrawerContent.tsx index e83bc9b..f1d8d50 100644 --- a/apps/ui/src/components/graph/NodeDrawerContent.tsx +++ b/apps/ui/src/components/graph/NodeDrawerContent.tsx @@ -28,7 +28,7 @@ import { Sparkles, Zap, } from 'lucide-react'; -import type { NodeDrawerData, PhaseType } from '~/lib/ast-to-graph'; +import type { NodeDrawerData, PhaseType } from '@agentscript/graph-ui'; import { useAppStore } from '~/store'; interface NodeDrawerContentProps { diff --git a/apps/ui/src/index.css b/apps/ui/src/index.css index 28fbacd..6e317d2 100644 --- a/apps/ui/src/index.css +++ b/apps/ui/src/index.css @@ -10,6 +10,8 @@ @plugin '@tailwindcss/typography'; @plugin 'tailwind-scrollbar'; +@source '../../../packages/graph-ui/src/**/*.{ts,tsx}'; + /* Syntax highlighting for diff view */ .syntax-keyword { color: #8250df; diff --git a/apps/ui/src/lib/ast-utils.ts b/apps/ui/src/lib/ast-utils.ts deleted file mode 100644 index fe80634..0000000 --- a/apps/ui/src/lib/ast-utils.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright (c) 2026, Salesforce, Inc. - * All rights reserved. - * SPDX-License-Identifier: Apache-2.0 - * For full license text, see the LICENSE file in the repo root or https://www.apache.org/licenses/LICENSE-2.0 - */ - -import { isNamedMap } from '@agentscript/language'; -import type { AgentScriptAST } from '~/lib/parser'; - -/** Find a topic block by name in either start_agent or topic maps. */ -export function findTopicBlock( - ast: AgentScriptAST, - name: string -): unknown | null { - if (isNamedMap(ast.start_agent) && ast.start_agent.has(name)) { - return ast.start_agent.get(name) ?? null; - } - if (isNamedMap(ast.topic) && ast.topic.has(name)) { - return ast.topic.get(name) ?? null; - } - return null; -} diff --git a/apps/ui/src/pages/Builder.tsx b/apps/ui/src/pages/Builder.tsx index 5fc9ca1..4924e5b 100644 --- a/apps/ui/src/pages/Builder.tsx +++ b/apps/ui/src/pages/Builder.tsx @@ -23,7 +23,7 @@ import { } from '~/components/explorer/astToTreeData'; import { AddBlockMenu } from '~/components/builder/AddBlockMenu'; import { ErrorBoundary } from '~/components/shared/ErrorBoundary'; -import { DiagnosticHoverCard } from '~/components/graph/nodes/DiagnosticHoverCard'; +import { DiagnosticHoverCard } from '@agentscript/graph-ui'; import { formatFieldName } from '~/lib/schema-introspection'; /** diff --git a/apps/ui/src/pages/Graph.tsx b/apps/ui/src/pages/Graph.tsx index da107eb..41a1824 100644 --- a/apps/ui/src/pages/Graph.tsx +++ b/apps/ui/src/pages/Graph.tsx @@ -5,294 +5,110 @@ * For full license text, see the LICENSE file in the repo root or https://www.apache.org/licenses/LICENSE-2.0 */ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback } from 'react'; import { useParams, useNavigate } from 'react-router'; -import { - ReactFlow, - Background, - Controls, - useNodesState, - useReactFlow, - ReactFlowProvider, - type NodeMouseHandler, - type ColorMode, -} from '@xyflow/react'; -import '@xyflow/react/dist/style.css'; +import { ChevronLeft } from 'lucide-react'; +import { Graph as SharedGraph } from '@agentscript/graph-ui'; +import type { ParsedAgentforce as AgentScriptAST } from '@agentscript/agentforce-dialect'; import { useAppStore } from '~/store'; -import type { AgentScriptAST } from '~/lib/parser'; -import { - astToOverviewGraph, - astToTopicDetailGraph, - type GraphNode, - type GraphEdge, -} from '~/lib/ast-to-graph'; -import { - applyDagreOverviewLayout, - applyDagreDetailLayout, -} from '~/lib/graph-layout'; -import { graphNodeTypes } from '~/components/graph/nodes'; -import { graphEdgeTypes } from '~/components/graph/edges'; import { ErrorBoundary } from '~/components/shared/ErrorBoundary'; -import { ChevronLeft } from 'lucide-react'; import { PanelHeader } from '~/components/panels/PanelHeader'; import { Button } from '~/components/ui/button'; -import { findPathEdges } from '~/lib/graph-path'; import { GraphDrawer } from '~/components/graph/GraphDrawer'; -const defaultEdgeOptions = { - style: { stroke: '#64748b', strokeWidth: 2 }, - markerEnd: { - type: 'arrowclosed' as const, - color: '#64748b', - width: 18, - height: 18, - }, -}; - -/** Inject connectedHandles sets into node data after layout. */ -function injectConnectedHandles( - nodes: GraphNode[], - connectedHandles: Map> -): GraphNode[] { - return nodes.map(node => { - const connected = connectedHandles.get(node.id); - if (connected) { - return { ...node, data: { ...node.data, connectedHandles: connected } }; - } - return node; - }); -} - -function GraphInner() { +export function Graph() { const { agentId, topicId } = useParams(); const navigate = useNavigate(); - const ast = useAppStore(state => state.source.ast) as AgentScriptAST | null; - const setSelectedNodeId = useAppStore(state => state.setSelectedNodeId); + const ast = useAppStore( + state => state.source.ast + ) as unknown as AgentScriptAST | null; const theme = useAppStore(state => state.theme.theme); - const closeGraphDrawer = useAppStore(state => state.closeGraphDrawer); + const setSelectedNodeId = useAppStore(state => state.setSelectedNodeId); const openGraphDrawer = useAppStore(state => state.openGraphDrawer); - const setHighlightedEdgeIds = useAppStore( - state => state.setHighlightedEdgeIds - ); - const { fitView } = useReactFlow(); + const openActionDrawer = useAppStore(state => state.openActionDrawer); + const closeGraphDrawer = useAppStore(state => state.closeGraphDrawer); const isTopicDetail = !!topicId; - // Graph data from AST (before layout) - const rawGraph = useMemo(() => { - if (!ast) return { nodes: [] as GraphNode[], edges: [] as GraphEdge[] }; - - if (isTopicDetail) { - return astToTopicDetailGraph(ast, topicId!); - } - - return astToOverviewGraph(ast); - }, [ast, isTopicDetail, topicId]); - - // Synchronous detail layout (computed directly, no effect needed) - const detailLayout = useMemo(() => { - if (!isTopicDetail || rawGraph.nodes.length === 0) return null; - const result = applyDagreDetailLayout(rawGraph.nodes, rawGraph.edges); - const nodesWithHandles = injectConnectedHandles( - result.nodes, - result.connectedHandles - ); - return { nodes: nodesWithHandles, edges: result.edges }; - }, [rawGraph, isTopicDetail]); - - // Synchronous overview layout - const overviewLayout = useMemo(() => { - if (isTopicDetail || rawGraph.nodes.length === 0) return null; - const layoutableNodes = rawGraph.nodes.filter( - n => n.data.nodeType !== 'reasoning-group' - ); - const result = applyDagreOverviewLayout(layoutableNodes, rawGraph.edges, { - direction: 'TB', - }); - const nodesWithHandles = injectConnectedHandles( - result.nodes, - result.connectedHandles - ); - return { nodes: nodesWithHandles, edges: result.edges }; - }, [rawGraph, isTopicDetail]); - - // Derive final layout - const layoutNodes = useMemo(() => { - if (detailLayout) return detailLayout.nodes; - if (overviewLayout) return overviewLayout.nodes; - return []; - }, [detailLayout, overviewLayout]); - const layoutEdges = useMemo(() => { - if (detailLayout) return detailLayout.edges; - if (overviewLayout) return overviewLayout.edges; - return []; - }, [detailLayout, overviewLayout]); - - // Node state (needs useNodesState for dragging/selection via onNodesChange) - const [nodes, setNodes, onNodesChange] = useNodesState(layoutNodes); - - // Path highlighting state (overview only) - const [selectedGraphNodeId, setSelectedGraphNodeId] = useState( - null - ); - - // Compute highlighted edge IDs and push to store for edge components - useEffect(() => { - if (!selectedGraphNodeId || isTopicDetail) { - setHighlightedEdgeIds(null); - return; - } - const ids = findPathEdges(layoutEdges, 'start', selectedGraphNodeId); - setHighlightedEdgeIds(ids); - }, [selectedGraphNodeId, layoutEdges, isTopicDetail, setHighlightedEdgeIds]); - - // Clear highlighting on unmount - useEffect(() => { - return () => setHighlightedEdgeIds(null); - }, [setHighlightedEdgeIds]); - - // Sync node layout + fit view on layout changes - useEffect(() => { - setNodes(layoutNodes); - requestAnimationFrame(() => { - void fitView({ padding: 0.2, duration: 300 }); - }); - }, [layoutNodes, setNodes, fitView]); - - // Double-click topic → navigate to topic detail - const handleNodeDoubleClick: NodeMouseHandler = useCallback( - (_event, node) => { - if ( - !isTopicDetail && - (node.data.nodeType === 'topic' || - node.data.nodeType === 'start-agent') && - node.data.topicName - ) { - void navigate(`/agents/${agentId}/graph/${node.data.topicName}`); - } - }, - [agentId, isTopicDetail, navigate] - ); - - // Single-click → sync with explorer + path highlighting + open drawer - const handleNodeClick: NodeMouseHandler = useCallback( - (_event, node) => { - // Path highlighting (overview only) - if (!isTopicDetail) { - setSelectedGraphNodeId(node.id); - } - - if (node.data.topicName) { - const isStartAgent = node.data.isStartAgent; - const prefix = isStartAgent ? 'start_agent' : 'topic'; - setSelectedNodeId(`${prefix}-${node.data.topicName}`); - } - - // Open node detail drawer (detail view only, skip non-interactive containers) - if (isTopicDetail && node.data.nodeType !== 'reasoning-group') { - openGraphDrawer({ - type: 'node', - data: { - nodeId: node.id, - nodeType: node.data.nodeType, - label: node.data.label, - subtitle: node.data.subtitle, - topicName: node.data.topicName, - conditionText: node.data.conditionText, - conditionLabel: node.data.conditionLabel, - transitionTarget: node.data.transitionTarget, - phaseType: node.data.phaseType, - actionNames: node.data.actionNames, - actionKeys: node.data.actionKeys, - isEmpty: node.data.isEmpty, - }, - }); - } + const handleTopicOpen = useCallback( + (topicName: string) => { + void navigate(`/agents/${agentId}/graph/${topicName}`); }, - [setSelectedNodeId, isTopicDetail, openGraphDrawer] + [agentId, navigate] ); - // Click background → clear selection + close drawer - const handlePaneClick = useCallback(() => { - setSelectedGraphNodeId(null); - closeGraphDrawer(); - }, [closeGraphDrawer]); - const handleBackToOverview = useCallback(() => { void navigate(`/agents/${agentId}/graph`); }, [agentId, navigate]); - // Resolve color mode for React Flow - const colorMode: ColorMode = - theme === 'dark' ? 'dark' : theme === 'light' ? 'light' : 'system'; - return ( -
- - - - ) : null - } - /> - - {/* Graph Canvas */} -
- {nodes.length === 0 ? ( -
- No topics defined. Add topics in the Script or Builder view. -
- ) : ( - - - - - )} - + +
+ + + + ) : null + } + /> + +
+ + openGraphDrawer({ + type: 'conditional', + data: { + conditionText: payload.conditionText, + sourceTopicName: payload.sourceTopicName, + conditionalKey: payload.conditionalKey, + }, + }) + } + onNodeClick={payload => { + if (payload.topicName) { + const prefix = payload.isStartAgent ? 'start_agent' : 'topic'; + setSelectedNodeId(`${prefix}-${payload.topicName}`); + } + if (isTopicDetail && payload.nodeType !== 'reasoning-group') { + openGraphDrawer({ + type: 'node', + data: { + nodeId: payload.nodeId, + nodeType: payload.data.nodeType, + label: payload.data.label, + subtitle: payload.data.subtitle, + topicName: payload.data.topicName, + conditionText: payload.data.conditionText, + conditionLabel: payload.data.conditionLabel, + transitionTarget: payload.data.transitionTarget, + phaseType: payload.data.phaseType, + actionNames: payload.data.actionNames, + actionKeys: payload.data.actionKeys, + isEmpty: payload.data.isEmpty, + }, + }); + } + }} + onPaneClick={closeGraphDrawer} + /> + +
-
- ); -} - -/** - * Graph page — wraps the inner component with ReactFlowProvider - * so useReactFlow() is available. - */ -export function Graph() { - return ( - - - - - + ); } diff --git a/apps/ui/src/store/layout.ts b/apps/ui/src/store/layout.ts index 5671650..1b5b809 100644 --- a/apps/ui/src/store/layout.ts +++ b/apps/ui/src/store/layout.ts @@ -5,7 +5,10 @@ * For full license text, see the LICENSE file in the repo root or https://www.apache.org/licenses/LICENSE-2.0 */ -import type { ActionDrawerData, GraphDrawerPayload } from '~/lib/ast-to-graph'; +import type { + ActionDrawerData, + GraphDrawerPayload, +} from '@agentscript/graph-ui'; // Layout state slice export interface LayoutState { diff --git a/packages/graph-ui/package.json b/packages/graph-ui/package.json new file mode 100644 index 0000000..c8c9f82 --- /dev/null +++ b/packages/graph-ui/package.json @@ -0,0 +1,40 @@ +{ + "name": "@agentscript/graph-ui", + "version": "0.1.0", + "private": true, + "description": "Shared React graph UI for AgentScript (apps/ui + packages/vscode webview)", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "files": [ + "src" + ], + "scripts": { + "typecheck": "tsc --noEmit", + "lint": "eslint .", + "clean": "rm -rf node_modules/.tmp" + }, + "dependencies": { + "@agentscript/agentforce-dialect": "workspace:*", + "@agentscript/language": "workspace:*", + "@agentscript/types": "workspace:*", + "clsx": "^2.1.1", + "tailwind-merge": "^3.3.1" + }, + "peerDependencies": { + "@dagrejs/dagre": "^2.0.4", + "@xyflow/react": "^12.10.0", + "lucide-react": "^0.545.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", + "typescript": "^5.8.3" + }, + "license": "Apache-2.0" +} diff --git a/packages/graph-ui/src/Graph.tsx b/packages/graph-ui/src/Graph.tsx new file mode 100644 index 0000000..4c2d0b6 --- /dev/null +++ b/packages/graph-ui/src/Graph.tsx @@ -0,0 +1,244 @@ +/* + * Copyright (c) 2026, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * For full license text, see the LICENSE file in the repo root or https://www.apache.org/licenses/LICENSE-2.0 + */ + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + ReactFlow, + Background, + Controls, + useNodesState, + useReactFlow, + ReactFlowProvider, + type NodeMouseHandler, + type ColorMode, +} from '@xyflow/react'; +import '@xyflow/react/dist/style.css'; + +import { + astToOverviewGraph, + astToTopicDetailGraph, + type GraphNode, + type GraphEdge, + type GraphNodeData, +} from './ast/ast-to-graph'; +import { + applyDagreOverviewLayout, + applyDagreDetailLayout, +} from './ast/graph-layout'; +import { findPathEdges } from './ast/graph-path'; +import { graphNodeTypes } from './components/nodes'; +import { graphEdgeTypes } from './components/edges'; +import { + GraphContextProvider, + type ActionClickPayload, + type ConditionalClickPayload, +} from './context/GraphContext'; +import type { AgentScriptAST } from './ast/ast-utils'; + +export interface GraphNodeClickPayload { + nodeId: string; + nodeType: string; + topicName: string | undefined; + isStartAgent: boolean; + /** Raw node data for host-specific drawers. */ + data: GraphNodeData; +} + +export interface GraphProps { + ast: AgentScriptAST | null; + /** Undefined = overview view; set = topic detail view for that topic name. */ + topicId?: string; + theme: 'light' | 'dark' | 'system'; + /** Called when user double-clicks a topic node in the overview. */ + onTopicOpen?: (topicName: string, isStartAgent: boolean) => void; + /** Called when an LLM action pill is clicked (detail view). */ + onActionClick?: (payload: ActionClickPayload) => void; + /** Called when a conditional edge gate icon is clicked. */ + onConditionalClick?: (payload: ConditionalClickPayload) => void; + /** Called on single-click — host syncs selection/drawer state. */ + onNodeClick?: (payload: GraphNodeClickPayload) => void; + /** Called when user clicks the pane background. */ + onPaneClick?: () => void; + /** Content to render when there are no nodes. */ + emptyMessage?: string; +} + +const defaultEdgeOptions = { + style: { stroke: '#64748b', strokeWidth: 2 }, +}; + +/** Inject connectedHandles sets into node data after layout. */ +function injectConnectedHandles( + nodes: GraphNode[], + connectedHandles: Map> +): GraphNode[] { + return nodes.map(node => { + const connected = connectedHandles.get(node.id); + if (connected) { + return { ...node, data: { ...node.data, connectedHandles: connected } }; + } + return node; + }); +} + +function GraphInner({ + ast, + topicId, + theme, + onTopicOpen, + onActionClick, + onConditionalClick, + onNodeClick, + onPaneClick, + emptyMessage, +}: GraphProps) { + const { fitView } = useReactFlow(); + const isTopicDetail = !!topicId; + + const rawGraph = useMemo(() => { + if (!ast) return { nodes: [] as GraphNode[], edges: [] as GraphEdge[] }; + if (isTopicDetail) return astToTopicDetailGraph(ast, topicId!); + return astToOverviewGraph(ast); + }, [ast, isTopicDetail, topicId]); + + const detailLayout = useMemo(() => { + if (!isTopicDetail || rawGraph.nodes.length === 0) return null; + const result = applyDagreDetailLayout(rawGraph.nodes, rawGraph.edges); + const nodesWithHandles = injectConnectedHandles( + result.nodes, + result.connectedHandles + ); + return { nodes: nodesWithHandles, edges: result.edges }; + }, [rawGraph, isTopicDetail]); + + const overviewLayout = useMemo(() => { + if (isTopicDetail || rawGraph.nodes.length === 0) return null; + const layoutableNodes = rawGraph.nodes.filter( + n => n.data.nodeType !== 'reasoning-group' + ); + const result = applyDagreOverviewLayout(layoutableNodes, rawGraph.edges, { + direction: 'TB', + }); + const nodesWithHandles = injectConnectedHandles( + result.nodes, + result.connectedHandles + ); + return { nodes: nodesWithHandles, edges: result.edges }; + }, [rawGraph, isTopicDetail]); + + const layoutNodes = useMemo(() => { + if (detailLayout) return detailLayout.nodes; + if (overviewLayout) return overviewLayout.nodes; + return []; + }, [detailLayout, overviewLayout]); + const layoutEdges = useMemo(() => { + if (detailLayout) return detailLayout.edges; + if (overviewLayout) return overviewLayout.edges; + return []; + }, [detailLayout, overviewLayout]); + + const [nodes, setNodes, onNodesChange] = useNodesState(layoutNodes); + const [selectedGraphNodeId, setSelectedGraphNodeId] = useState( + null + ); + + const highlightedEdgeIds = useMemo | null>(() => { + if (!selectedGraphNodeId || isTopicDetail) return null; + return findPathEdges(layoutEdges, 'start', selectedGraphNodeId); + }, [selectedGraphNodeId, layoutEdges, isTopicDetail]); + + useEffect(() => { + setNodes(layoutNodes); + requestAnimationFrame(() => { + void fitView({ padding: 0.2, duration: 300 }); + }); + }, [layoutNodes, setNodes, fitView]); + + const handleNodeDoubleClick: NodeMouseHandler = useCallback( + (_event, node) => { + const data = node.data as unknown as GraphNodeData; + if ( + !isTopicDetail && + (data.nodeType === 'topic' || data.nodeType === 'start-agent') && + typeof data.topicName === 'string' + ) { + onTopicOpen?.(data.topicName, !!data.isStartAgent); + } + }, + [isTopicDetail, onTopicOpen] + ); + + const handleNodeClick: NodeMouseHandler = useCallback( + (_event, node) => { + if (!isTopicDetail) setSelectedGraphNodeId(node.id); + const data = node.data as unknown as GraphNodeData; + onNodeClick?.({ + nodeId: node.id, + nodeType: data.nodeType, + topicName: data.topicName, + isStartAgent: !!data.isStartAgent, + data, + }); + }, + [isTopicDetail, onNodeClick] + ); + + const handlePaneClick = useCallback(() => { + setSelectedGraphNodeId(null); + onPaneClick?.(); + }, [onPaneClick]); + + const colorMode: ColorMode = + theme === 'dark' ? 'dark' : theme === 'light' ? 'light' : 'system'; + + return ( + +
+ {nodes.length === 0 ? ( +
+ {emptyMessage ?? + 'No topics defined. Add topics in the Script or Builder view.'} +
+ ) : ( + + + + + )} +
+
+ ); +} + +export function Graph(props: GraphProps) { + return ( + + + + ); +} diff --git a/apps/ui/src/lib/ast-to-graph.ts b/packages/graph-ui/src/ast/ast-to-graph.ts similarity index 93% rename from apps/ui/src/lib/ast-to-graph.ts rename to packages/graph-ui/src/ast/ast-to-graph.ts index b5e6349..9829331 100644 --- a/apps/ui/src/lib/ast-to-graph.ts +++ b/packages/graph-ui/src/ast/ast-to-graph.ts @@ -23,8 +23,7 @@ import { type Diagnostic, type Statement, } from '@agentscript/language'; -import type { AgentScriptAST } from '~/lib/parser'; -import { findTopicBlock } from '~/lib/ast-utils'; +import { findTopicBlock, type AgentScriptAST } from './ast-utils'; // --------------------------------------------------------------------------- // Types @@ -53,6 +52,38 @@ export type PhaseType = | 'after_reasoning' | 'before_reasoning_iteration'; +/** Zero-based line/column range into the source file. */ +export interface SourceRange { + startLine: number; + startCol: number; + endLine: number; + endCol: number; +} + +/** + * Read the CST range attached to a parsed AST block or statement. + * Returns undefined on synthetic nodes that lack a backing CST node. + */ +export function getSourceRange(block: unknown): SourceRange | undefined { + if (!block || typeof block !== 'object') return undefined; + const cst = (block as { __cst?: unknown }).__cst as + | { + range?: { + start?: { line?: number; character?: number }; + end?: { line?: number; character?: number }; + }; + } + | undefined; + const range = cst?.range; + if (!range?.start || !range?.end) return undefined; + return { + startLine: range.start.line ?? 0, + startCol: range.start.character ?? 0, + endLine: range.end.line ?? 0, + endCol: range.end.character ?? 0, + }; +} + /** Well-known group container IDs for post-layout positioning. */ export const GROUP_IDS = { beforeReasoning: 'group-before-reasoning', @@ -75,6 +106,8 @@ export interface GraphNodeData extends Record { actionNames?: string[]; /** Raw action map keys (parallel to actionNames) for AST lookup. */ actionKeys?: string[]; + /** Source ranges for each action, parallel to actionNames/actionKeys. */ + actionRanges?: Array; diagnostics?: Diagnostic[]; /** Phase type for phase/phase-label nodes */ phaseType?: PhaseType; @@ -90,6 +123,8 @@ export interface GraphNodeData extends Record { connectedHandles?: ReadonlySet; /** Horizontal offset from container left edge to spine center (for group handle positioning). */ spineOffsetX?: number; + /** Source range for jump-to-code (vscode webview). */ + sourceRange?: SourceRange; } /** Data attached to conditional edges for the drawer. */ @@ -206,10 +241,10 @@ function extractTransitions( for (const stmt of statements) { if (stmt.__kind === 'TransitionStatement') { - const transition = stmt as { clauses: Statement[] }; + const transition = stmt as unknown as { clauses: Statement[] }; for (const clause of transition.clauses) { if (clause.__kind === 'ToClause') { - const toClause = clause as { target: unknown }; + const toClause = clause as unknown as { target: unknown }; const target = resolveTransitionTarget(toClause.target); if (target) { transitions.push({ @@ -222,7 +257,7 @@ function extractTransitions( } } } else if (stmt.__kind === 'IfStatement') { - const ifStmt = stmt as { + const ifStmt = stmt as unknown as { condition?: { __emit?(ctx: { indent: number }): string }; body: Statement[]; orelse: Statement[]; @@ -323,16 +358,16 @@ function extractTransitionsFromReasoningAction( const transitions: TransitionInfo[] = []; for (const stmt of statements) { if (stmt.__kind === 'ToClause') { - const toClause = stmt as { target: unknown }; + const toClause = stmt as unknown as { target: unknown }; const target = resolveTransitionTarget(toClause.target); if (target) { transitions.push({ targetTopicName: target }); } } else if (stmt.__kind === 'TransitionStatement') { - const transition = stmt as { clauses: Statement[] }; + const transition = stmt as unknown as { clauses: Statement[] }; for (const clause of transition.clauses) { if (clause.__kind === 'ToClause') { - const toClause = clause as { target: unknown }; + const toClause = clause as unknown as { target: unknown }; const target = resolveTransitionTarget(toClause.target); if (target) { transitions.push({ targetTopicName: target }); @@ -503,6 +538,7 @@ export function astToOverviewGraph(ast: AgentScriptAST): { isStartAgent: true, topicName: name, diagnostics: blockDiagnostics, + sourceRange: getSourceRange(block), }, }); @@ -535,6 +571,7 @@ export function astToOverviewGraph(ast: AgentScriptAST): { blockType: 'topic', topicName: name, diagnostics: blockDiagnostics, + sourceRange: getSourceRange(block), }, }); @@ -659,6 +696,7 @@ export function astToTopicDetailGraph( phaseType: 'topic-header', topicName, diagnostics: topicDiagnostics, + sourceRange: getSourceRange(topicBlock), }, }); connectPipeline(headerId); @@ -679,6 +717,7 @@ export function astToTopicDetailGraph( label: 'Before Reasoning', blockType: 'topic', isEmpty: beforeEmpty, + sourceRange: getSourceRange(topicBlock.before_reasoning), }, }); @@ -697,6 +736,7 @@ export function astToTopicDetailGraph( groupId: GROUP_IDS.beforeReasoning, topicName, isEmpty: beforeEmpty, + sourceRange: getSourceRange(topicBlock.before_reasoning), }, }); // Route spine through the before-reasoning group handles: @@ -768,6 +808,7 @@ export function astToTopicDetailGraph( nodeType: 'reasoning-group', label: 'Reasoning Loop', blockType: 'topic', + sourceRange: getSourceRange(reasoning), }, }); @@ -795,6 +836,7 @@ export function astToTopicDetailGraph( blockType: 'topic', phaseType: 'before_reasoning_iteration', groupId: GROUP_IDS.reasoningLoop, + sourceRange: getSourceRange(reasoning?.before_reasoning_iteration), }, }); @@ -854,6 +896,7 @@ export function astToTopicDetailGraph( label: 'Build Instructions', blockType: 'topic', groupId: GROUP_IDS.reasoningLoop, + sourceRange: getSourceRange(reasoning?.instructions), }, }); // Tag as spine for layout positioning but no edge from iteration @@ -886,6 +929,7 @@ export function astToTopicDetailGraph( | undefined; const actionDisplayNames: string[] = []; const actionKeyNames: string[] = []; + const actionRanges: Array = []; if (isNamedMap(reasoningActions)) { for (const [actionName, actionBlock] of reasoningActions) { const actionLabel = @@ -893,6 +937,7 @@ export function astToTopicDetailGraph( toDisplayLabel(actionName); actionDisplayNames.push(actionLabel); actionKeyNames.push(actionName); + actionRanges.push(getSourceRange(actionBlock)); } } @@ -909,7 +954,10 @@ export function astToTopicDetailGraph( groupId: GROUP_IDS.reasoningLoop, actionNames: actionDisplayNames, actionKeys: actionKeyNames, + actionRanges, topicName, + sourceRange: + getSourceRange(reasoning?.actions) ?? getSourceRange(reasoning), }, }); connectPipeline(llmId); @@ -972,6 +1020,7 @@ export function astToTopicDetailGraph( label: 'After Reasoning', blockType: 'topic', isEmpty: afterEmpty, + sourceRange: getSourceRange(topicBlock.after_reasoning), }, }); @@ -988,6 +1037,7 @@ export function astToTopicDetailGraph( blockType: 'topic', phaseType: 'after_reasoning', groupId: GROUP_IDS.afterReasoning, + sourceRange: getSourceRange(topicBlock.after_reasoning), }, }); // Route spine through the after-reasoning group handles: @@ -1100,10 +1150,10 @@ function buildDetailNodes( for (const stmt of statements) { if (stmt.__kind === 'TransitionStatement') { - const transition = stmt as { clauses: Statement[] }; + const transition = stmt as unknown as { clauses: Statement[] }; for (const clause of transition.clauses) { if (clause.__kind === 'ToClause') { - const toClause = clause as { target: unknown }; + const toClause = clause as unknown as { target: unknown }; const target = resolveTransitionTarget(toClause.target); if (target) { const transId = `transition-${counters.getTransIdx()}`; @@ -1118,6 +1168,7 @@ function buildDetailNodes( blockType: 'topic', transitionTarget: target, groupId, + sourceRange: getSourceRange(stmt), }, }); edges.push({ @@ -1133,7 +1184,7 @@ function buildDetailNodes( } // Transition is terminal — don't advance chain } else if (stmt.__kind === 'IfStatement') { - const ifStmt = stmt as { + const ifStmt = stmt as unknown as { condition?: { __emit?(ctx: { indent: number }): string }; body: Statement[]; orelse: Statement[]; @@ -1153,6 +1204,7 @@ function buildDetailNodes( conditionText: condText, conditionLabel: abbreviateCondition(condText), groupId, + sourceRange: getSourceRange(stmt), }, }); @@ -1192,7 +1244,7 @@ function buildDetailNodes( } // Branching — don't advance chain (subsequent statements fan from same source) } else if (stmt.__kind === 'RunStatement') { - const runStmt = stmt as { target: unknown; body: Statement[] }; + const runStmt = stmt as unknown as { target: unknown; body: Statement[] }; const decomposed = decomposeAtMemberExpression(runStmt.target); if (decomposed) { const runId = `run-${counters.getRunIdx()}`; @@ -1206,6 +1258,7 @@ function buildDetailNodes( subtitle: `@${decomposed.namespace}`, blockType: 'actions', groupId, + sourceRange: getSourceRange(stmt), }, }); edges.push({ @@ -1234,7 +1287,7 @@ function buildDetailNodes( } } } else if (stmt.__kind === 'SetClause') { - const setStmt = stmt as { + const setStmt = stmt as unknown as { target: unknown; value?: { __emit?(ctx: { indent: number }): string; value?: unknown }; }; @@ -1254,6 +1307,7 @@ function buildDetailNodes( subtitle: valueText, blockType: 'set', groupId, + sourceRange: getSourceRange(stmt), }, }); edges.push({ @@ -1269,7 +1323,7 @@ function buildDetailNodes( curHandle = undefined; } } else if (stmt.__kind === 'Template') { - const tpl = stmt as { + const tpl = stmt as unknown as { parts: Array<{ __kind: string; value?: string; @@ -1300,6 +1354,7 @@ function buildDetailNodes( label: templateText, blockType: 'template', groupId, + sourceRange: getSourceRange(stmt), }, }); edges.push({ @@ -1346,7 +1401,7 @@ function buildDetailNodesFromReasoningAction( ): void { for (const stmt of statements) { if (stmt.__kind === 'ToClause') { - const toClause = stmt as { target: unknown }; + const toClause = stmt as unknown as { target: unknown }; const target = resolveTransitionTarget(toClause.target); if (target) { const transId = `transition-${counters.getTransIdx()}`; @@ -1372,10 +1427,10 @@ function buildDetailNodesFromReasoningAction( }); } } else if (stmt.__kind === 'TransitionStatement') { - const transition = stmt as { clauses: Statement[] }; + const transition = stmt as unknown as { clauses: Statement[] }; for (const clause of transition.clauses) { if (clause.__kind === 'ToClause') { - const toClause = clause as { target: unknown }; + const toClause = clause as unknown as { target: unknown }; const target = resolveTransitionTarget(toClause.target); if (target) { const transId = `transition-${counters.getTransIdx()}`; @@ -1390,6 +1445,7 @@ function buildDetailNodesFromReasoningAction( blockType: 'topic', transitionTarget: target, groupId, + sourceRange: getSourceRange(stmt), }, }); edges.push({ diff --git a/packages/graph-ui/src/ast/ast-utils.ts b/packages/graph-ui/src/ast/ast-utils.ts new file mode 100644 index 0000000..aba88f7 --- /dev/null +++ b/packages/graph-ui/src/ast/ast-utils.ts @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2026, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * For full license text, see the LICENSE file in the repo root or https://www.apache.org/licenses/LICENSE-2.0 + */ + +import { isNamedMap } from '@agentscript/language'; +import type { ParsedAgentforce } from '@agentscript/agentforce-dialect'; + +export type AgentScriptAST = ParsedAgentforce; + +/** Find a topic block by name in either start_agent or topic maps. */ +export function findTopicBlock(ast: unknown, name: string): unknown | null { + const root = ast as { start_agent?: unknown; topic?: unknown }; + if (isNamedMap(root.start_agent) && root.start_agent.has(name)) { + return root.start_agent.get(name) ?? null; + } + if (isNamedMap(root.topic) && root.topic.has(name)) { + return root.topic.get(name) ?? null; + } + return null; +} diff --git a/apps/ui/src/lib/graph-layout.ts b/packages/graph-ui/src/ast/graph-layout.ts similarity index 99% rename from apps/ui/src/lib/graph-layout.ts rename to packages/graph-ui/src/ast/graph-layout.ts index 19dcde7..ab5f224 100644 --- a/apps/ui/src/lib/graph-layout.ts +++ b/packages/graph-ui/src/ast/graph-layout.ts @@ -24,7 +24,7 @@ import { LLM_SIDES, BUILD_INSTRUCTIONS_SIDES, type HandleSide, -} from '~/components/graph/nodes/NodeHandles'; +} from '../components/nodes/NodeHandles'; // --------------------------------------------------------------------------- // Options diff --git a/apps/ui/src/lib/graph-path.ts b/packages/graph-ui/src/ast/graph-path.ts similarity index 100% rename from apps/ui/src/lib/graph-path.ts rename to packages/graph-ui/src/ast/graph-path.ts diff --git a/apps/ui/src/components/graph/edges/AnimatedEdge.tsx b/packages/graph-ui/src/components/edges/AnimatedEdge.tsx similarity index 78% rename from apps/ui/src/components/graph/edges/AnimatedEdge.tsx rename to packages/graph-ui/src/components/edges/AnimatedEdge.tsx index 04ad154..3aa0c7a 100644 --- a/apps/ui/src/components/graph/edges/AnimatedEdge.tsx +++ b/packages/graph-ui/src/components/edges/AnimatedEdge.tsx @@ -6,7 +6,7 @@ */ import { BaseEdge, getSmoothStepPath, type EdgeProps } from '@xyflow/react'; -import { useAppStore } from '~/store'; +import { useGraphContext } from '../../context/GraphContext'; const CHEVRON_COUNT = 2; const DURATION = 2.4; @@ -46,9 +46,7 @@ export function AnimatedEdge({ const edgeRole = edgeData?.edgeRole as string | undefined; const isSpine = edgeRole === 'spine'; - const highlightedEdgeIds = useAppStore( - state => state.layout.highlightedEdgeIds - ); + const { highlightedEdgeIds } = useGraphContext(); const isHighlighted = highlightedEdgeIds?.has(id) ?? false; const isDimmed = highlightedEdgeIds != null && !isHighlighted; @@ -110,22 +108,32 @@ export function AnimatedEdge({ filter: glowFilter, }} /> - {Array.from({ length: CHEVRON_COUNT }, (_, i) => ( - - - - ))} + {Array.from({ length: CHEVRON_COUNT }, (_, i) => { + const begin = `${(i * DURATION) / CHEVRON_COUNT}s`; + return ( + + + + + ); + })} ); } diff --git a/apps/ui/src/components/graph/edges/ConditionalEdge.tsx b/packages/graph-ui/src/components/edges/ConditionalEdge.tsx similarity index 75% rename from apps/ui/src/components/graph/edges/ConditionalEdge.tsx rename to packages/graph-ui/src/components/edges/ConditionalEdge.tsx index 95babbf..c45f9d6 100644 --- a/apps/ui/src/components/graph/edges/ConditionalEdge.tsx +++ b/packages/graph-ui/src/components/edges/ConditionalEdge.tsx @@ -12,8 +12,8 @@ import { type EdgeProps, } from '@xyflow/react'; import { ShieldCheck } from 'lucide-react'; -import { useAppStore } from '~/store'; -import type { ConditionalEdgeData } from '~/lib/ast-to-graph'; +import { useGraphContext } from '../../context/GraphContext'; +import type { ConditionalEdgeData } from '../../ast/ast-to-graph'; const COLOR = '#6366f1'; // indigo — intelligence color for decision paths const HIGHLIGHT_COLOR = '#3b82f6'; @@ -49,9 +49,7 @@ export function ConditionalEdge({ targetPosition, }); - const highlightedEdgeIds = useAppStore( - state => state.layout.highlightedEdgeIds - ); + const { highlightedEdgeIds, onConditionalClick } = useGraphContext(); const isHighlighted = highlightedEdgeIds?.has(id) ?? false; const isDimmed = highlightedEdgeIds != null && !isHighlighted; @@ -62,21 +60,17 @@ export function ConditionalEdge({ const chevronColor = isHighlighted ? HIGHLIGHT_COLOR : COLOR; const groupOpacity = isDimmed ? 0.1 : 1; - const openGraphDrawer = useAppStore(state => state.openGraphDrawer); - const edgeData = data as | (ConditionalEdgeData & Record) | undefined; const handleGateClick = () => { if (edgeData?.conditionText && edgeData?.sourceTopicName) { - openGraphDrawer({ - type: 'conditional', - data: { - conditionText: edgeData.conditionText, - sourceTopicName: edgeData.sourceTopicName, - conditionalKey: edgeData.conditionalKey ?? edgeData.conditionText, - }, + onConditionalClick?.({ + edgeId: id, + conditionText: edgeData.conditionText, + sourceTopicName: edgeData.sourceTopicName, + conditionalKey: edgeData.conditionalKey ?? edgeData.conditionText, }); } }; @@ -99,22 +93,32 @@ export function ConditionalEdge({ /> {/* Flowing chevrons */} - {Array.from({ length: CHEVRON_COUNT }, (_, i) => ( - - - - ))} + {Array.from({ length: CHEVRON_COUNT }, (_, i) => { + const begin = `${(i * DURATION) / CHEVRON_COUNT}s`; + return ( + + + + + ); + })} {/* Gate icon with hover tooltip + click to open drawer */} {label && ( diff --git a/apps/ui/src/components/graph/edges/LoopBackEdge.tsx b/packages/graph-ui/src/components/edges/LoopBackEdge.tsx similarity index 100% rename from apps/ui/src/components/graph/edges/LoopBackEdge.tsx rename to packages/graph-ui/src/components/edges/LoopBackEdge.tsx diff --git a/apps/ui/src/components/graph/edges/index.ts b/packages/graph-ui/src/components/edges/index.ts similarity index 100% rename from apps/ui/src/components/graph/edges/index.ts rename to packages/graph-ui/src/components/edges/index.ts diff --git a/apps/ui/src/components/graph/nodes/ActionNode.tsx b/packages/graph-ui/src/components/nodes/ActionNode.tsx similarity index 87% rename from apps/ui/src/components/graph/nodes/ActionNode.tsx rename to packages/graph-ui/src/components/nodes/ActionNode.tsx index 40b5cc9..e6fc862 100644 --- a/apps/ui/src/components/graph/nodes/ActionNode.tsx +++ b/packages/graph-ui/src/components/nodes/ActionNode.tsx @@ -7,17 +7,17 @@ import type { NodeProps } from '@xyflow/react'; import { Play } from 'lucide-react'; -import type { GraphNodeData } from '~/lib/ast-to-graph'; +import type { GraphNode } from '../../ast/ast-to-graph'; import { getNodeBorderClass } from './diagnosticBorder'; import { DiagnosticHoverCard } from './DiagnosticHoverCard'; import { NodeHandles, DETAIL_SIDES } from './NodeHandles'; -export function ActionNode({ data, selected }: NodeProps) { +export function ActionNode({ data, selected }: NodeProps) { const borderClass = getNodeBorderClass(selected, data.diagnostics); return (
) { +export function BuildInstructionsNode({ data }: NodeProps) { return ( -
+
) { +export function CompoundTopicNode({ data, selected }: NodeProps) { const sections = ALL_SECTIONS; const borderClass = getNodeBorderClass(selected, data.diagnostics); return (
+
@@ -69,7 +66,7 @@ export function CompoundTopicNode({ key={section} className={`relative flex items-center justify-between px-4 py-2.5 ${ idx < sections.length - 1 - ? 'border-b border-gray-50 dark:border-[#383838]' + ? 'border-b border-gray-50 dark:border-zinc-700' : '' }`} > diff --git a/apps/ui/src/components/graph/nodes/ConditionalNode.tsx b/packages/graph-ui/src/components/nodes/ConditionalNode.tsx similarity index 82% rename from apps/ui/src/components/graph/nodes/ConditionalNode.tsx rename to packages/graph-ui/src/components/nodes/ConditionalNode.tsx index aa01ac4..2668396 100644 --- a/apps/ui/src/components/graph/nodes/ConditionalNode.tsx +++ b/packages/graph-ui/src/components/nodes/ConditionalNode.tsx @@ -7,21 +7,21 @@ import { Handle, Position, type NodeProps } from '@xyflow/react'; import { Diamond } from 'lucide-react'; -import type { GraphNodeData } from '~/lib/ast-to-graph'; +import type { GraphNode } from '../../ast/ast-to-graph'; -export function ConditionalNode({ data }: NodeProps) { +export function ConditionalNode({ data }: NodeProps) { const conditionLabel = data.conditionLabel ?? data.conditionText ?? ''; const fullCondition = data.conditionText ?? ''; return (
-
+
{/* Single target handle — top center */} {/* Compact header row */} @@ -51,7 +51,7 @@ export function ConditionalNode({ data }: NodeProps) { type="source" position={Position.Bottom} id="if" - className="h-2! w-2! border-[1.5px]! border-white! bg-gray-400! dark:border-[#26262e]! dark:bg-gray-500!" + className="h-2! w-2! border-[1.5px]! border-white! bg-gray-400! dark:border-zinc-800! dark:bg-gray-500!" style={{ left: '30%' }} /> {/* "else" handle at 70% */} @@ -59,7 +59,7 @@ export function ConditionalNode({ data }: NodeProps) { type="source" position={Position.Bottom} id="else" - className="h-2! w-2! border-[1.5px]! border-white! bg-gray-400! dark:border-[#26262e]! dark:bg-gray-500!" + className="h-2! w-2! border-[1.5px]! border-white! bg-gray-400! dark:border-zinc-800! dark:bg-gray-500!" style={{ left: '70%' }} />
@@ -67,7 +67,7 @@ export function ConditionalNode({ data }: NodeProps) { {/* Hover tooltip — full condition text */} {fullCondition && (
-
+
Condition
diff --git a/apps/ui/src/components/graph/nodes/DiagnosticHoverCard.tsx b/packages/graph-ui/src/components/nodes/DiagnosticHoverCard.tsx similarity index 96% rename from apps/ui/src/components/graph/nodes/DiagnosticHoverCard.tsx rename to packages/graph-ui/src/components/nodes/DiagnosticHoverCard.tsx index 0939291..d1c65ab 100644 --- a/apps/ui/src/components/graph/nodes/DiagnosticHoverCard.tsx +++ b/packages/graph-ui/src/components/nodes/DiagnosticHoverCard.tsx @@ -7,7 +7,7 @@ import { DiagnosticSeverity } from '@agentscript/types'; import type { Diagnostic } from '@agentscript/types'; -import { cn } from '~/lib/utils'; +import { cn } from '../../utils'; import { CircleAlert, TriangleAlert, Info } from 'lucide-react'; interface DiagnosticHoverCardProps { @@ -40,7 +40,7 @@ export function DiagnosticHoverCard({ diagnostics }: DiagnosticHoverCardProps) { return (
{/* Badge (always visible) */} -
+
{errors.length > 0 && ( @@ -63,7 +63,7 @@ export function DiagnosticHoverCard({ diagnostics }: DiagnosticHoverCardProps) { {/* Hover tooltip */}
-
+
    {diagnostics.map((d, i) => (
  • diff --git a/apps/ui/src/components/graph/nodes/LlmNode.tsx b/packages/graph-ui/src/components/nodes/LlmNode.tsx similarity index 88% rename from apps/ui/src/components/graph/nodes/LlmNode.tsx rename to packages/graph-ui/src/components/nodes/LlmNode.tsx index b2a0330..a2bd9d7 100644 --- a/apps/ui/src/components/graph/nodes/LlmNode.tsx +++ b/packages/graph-ui/src/components/nodes/LlmNode.tsx @@ -7,14 +7,17 @@ import type { NodeProps } from '@xyflow/react'; import { Sparkles, Play } from 'lucide-react'; -import type { GraphNodeData } from '~/lib/ast-to-graph'; +import type { GraphNode, SourceRange } from '../../ast/ast-to-graph'; import { NodeHandles, LLM_SIDES } from './NodeHandles'; -import { useAppStore } from '~/store'; +import { useGraphContext } from '../../context/GraphContext'; -export function LlmNode({ data }: NodeProps) { +export function LlmNode({ data }: NodeProps) { const actionNames = data.actionNames as string[] | undefined; + const actionRanges = data.actionRanges as + | Array + | undefined; const hasActions = actionNames && actionNames.length > 0; - const openActionDrawer = useAppStore(state => state.openActionDrawer); + const { onActionClick } = useGraphContext(); const handleActionClick = ( e: React.MouseEvent, @@ -22,10 +25,11 @@ export function LlmNode({ data }: NodeProps) { index: number ) => { e.stopPropagation(); - openActionDrawer({ + onActionClick?.({ actionDisplayName: actionName, actionIndex: index, topicName: data.topicName as string | undefined, + sourceRange: actionRanges?.[index], }); }; diff --git a/apps/ui/src/components/graph/nodes/NodeHandles.tsx b/packages/graph-ui/src/components/nodes/NodeHandles.tsx similarity index 97% rename from apps/ui/src/components/graph/nodes/NodeHandles.tsx rename to packages/graph-ui/src/components/nodes/NodeHandles.tsx index 08c2a7a..22b2926 100644 --- a/apps/ui/src/components/graph/nodes/NodeHandles.tsx +++ b/packages/graph-ui/src/components/nodes/NodeHandles.tsx @@ -57,7 +57,7 @@ export interface NodeHandlesProps { const UNCONNECTED = '!h-[5px] !w-[5px] !border !border-gray-300/50 !bg-transparent dark:!border-gray-600/50'; const CONNECTED_BASE = - '!h-[7px] !w-[7px] !border-[1.5px] !border-white !shadow-sm dark:!border-[#2d2d2d]'; + '!h-[7px] !w-[7px] !border-[1.5px] !border-white !shadow-sm dark:!border-zinc-800'; function handleClass(connected: boolean, _accentColor?: string): string { if (!connected) return UNCONNECTED; @@ -145,13 +145,6 @@ export function NodeHandles({ ); } -/** - * All available handle IDs for a given side. - */ -export function getHandleIdsForSide(side: HandleSide): string[] { - return HANDLES_BY_SIDE[side].map(h => h.id); -} - /** * Standard handle sides for overview nodes (TB layout): * targets on top/left, sources on bottom/right. diff --git a/apps/ui/src/components/graph/nodes/PhaseNode.tsx b/packages/graph-ui/src/components/nodes/PhaseNode.tsx similarity index 94% rename from apps/ui/src/components/graph/nodes/PhaseNode.tsx rename to packages/graph-ui/src/components/nodes/PhaseNode.tsx index 875ee49..ea78f31 100644 --- a/apps/ui/src/components/graph/nodes/PhaseNode.tsx +++ b/packages/graph-ui/src/components/nodes/PhaseNode.tsx @@ -7,7 +7,7 @@ import type { NodeProps } from '@xyflow/react'; import { PlayCircle, CheckCircle, BookOpen } from 'lucide-react'; -import type { GraphNodeData, PhaseType } from '~/lib/ast-to-graph'; +import type { GraphNode, PhaseType } from '../../ast/ast-to-graph'; import { getNodeBorderClass } from './diagnosticBorder'; import { DiagnosticHoverCard } from './DiagnosticHoverCard'; import { NodeHandles, PHASE_SIDES, type HandleSide } from './NodeHandles'; @@ -71,7 +71,7 @@ function getSidesForPhase( return PHASE_SIDES; } -export function PhaseNode({ data, selected }: NodeProps) { +export function PhaseNode({ data, selected }: NodeProps) { const phaseType = data.phaseType as PhaseType | undefined; const config = (phaseType && PHASE_CONFIGS[phaseType]) ?? DEFAULT_CONFIG; const Icon = config.icon; @@ -83,7 +83,7 @@ export function PhaseNode({ data, selected }: NodeProps) { // Empty phase: dashed border, muted — shows lifecycle slot exists if (isEmpty) { return ( -
    +
    ) { return (
    ) { +export function ReasoningGroupNode({ data }: NodeProps) { const isLoop = data.label === 'Reasoning Loop'; const isEmpty = data.isEmpty === true; @@ -85,7 +85,7 @@ export function ReasoningGroupNode({ data }: NodeProps) { if (isEmpty && !isLoop) { return (
    {groupHandles} @@ -103,8 +103,8 @@ export function ReasoningGroupNode({ data }: NodeProps) {
    diff --git a/apps/ui/src/components/graph/nodes/RunNode.tsx b/packages/graph-ui/src/components/nodes/RunNode.tsx similarity index 81% rename from apps/ui/src/components/graph/nodes/RunNode.tsx rename to packages/graph-ui/src/components/nodes/RunNode.tsx index 4affa75..1178383 100644 --- a/apps/ui/src/components/graph/nodes/RunNode.tsx +++ b/packages/graph-ui/src/components/nodes/RunNode.tsx @@ -7,16 +7,16 @@ import type { NodeProps } from '@xyflow/react'; import { Play } from 'lucide-react'; -import type { GraphNodeData } from '~/lib/ast-to-graph'; +import type { GraphNode } from '../../ast/ast-to-graph'; import { getNodeBorderClass } from './diagnosticBorder'; import { NodeHandles, DETAIL_SIDES } from './NodeHandles'; -export function RunNode({ data, selected }: NodeProps) { +export function RunNode({ data, selected }: NodeProps) { const borderClass = getNodeBorderClass(selected, data.diagnostics); return (
    ) { +export function SetNode({ data, selected }: NodeProps) { const borderClass = getNodeBorderClass(selected, data.diagnostics); return (
    ) { +export function StartNode({ data, selected }: NodeProps) { return (
    ) { +export function TemplateNode({ data, selected }: NodeProps) { const text = data.label ?? ''; const borderClass = getNodeBorderClass(selected, data.diagnostics); return (
    ) { +export function TopicNode({ data, selected }: NodeProps) { const isStartAgent = !!data.isStartAgent; const config = getBlockTypeConfig(data.blockType, { isStartAgent, @@ -29,8 +29,8 @@ export function TopicNode({ data, selected }: NodeProps) { // Regular Topic: warm neutral surface — clearly above the #141414 canvas // Tinted bg separates from canvas, but border/text stay neutral — icon badge is the sole color accent const variantClasses = isStartAgent - ? 'bg-indigo-50 dark:bg-[#2a2a5c] border-indigo-200 dark:border-slate-400/20 dark:shadow-indigo-500/15 hover:border-indigo-300 dark:hover:border-slate-400/35' - : 'bg-sky-50 dark:bg-[#1e3a58] border-sky-200 dark:border-slate-400/20 dark:shadow-sky-500/15 hover:border-sky-300 dark:hover:border-slate-400/35'; + ? 'bg-indigo-50 dark:bg-zinc-800 border-indigo-300 dark:border-indigo-500/40 dark:shadow-indigo-500/10 hover:border-indigo-400 dark:hover:border-indigo-400/60' + : 'bg-sky-50 dark:bg-zinc-800 border-sky-300 dark:border-sky-500/40 dark:shadow-sky-500/10 hover:border-sky-400 dark:hover:border-sky-400/60'; return (
    ) { +export function TransitionNode({ data, selected }: NodeProps) { const borderClass = getNodeBorderClass(selected, data.diagnostics); return (
    | null; + /** Host callback when an LLM action pill is clicked. */ + onActionClick?: (payload: ActionClickPayload) => void; + /** Host callback when a conditional edge label is clicked. */ + onConditionalClick?: (payload: ConditionalClickPayload) => void; +} + +const GraphContext = createContext({ + highlightedEdgeIds: null, +}); + +export function GraphContextProvider({ + value, + children, +}: { + value: GraphContextValue; + children: ReactNode; +}) { + return ( + {children} + ); +} + +export function useGraphContext(): GraphContextValue { + return useContext(GraphContext); +} diff --git a/packages/graph-ui/src/index.ts b/packages/graph-ui/src/index.ts new file mode 100644 index 0000000..b634654 --- /dev/null +++ b/packages/graph-ui/src/index.ts @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2026, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * For full license text, see the LICENSE file in the repo root or https://www.apache.org/licenses/LICENSE-2.0 + */ + +export { Graph } from './Graph'; +export type { GraphProps, GraphNodeClickPayload } from './Graph'; + +// AST + layout +export { + astToOverviewGraph, + astToTopicDetailGraph, + type GraphNode, + type GraphEdge, + type GraphNodeData, + type GraphNodeType, + type PhaseType, + type ConditionalEdgeData, + type ActionDrawerData, + type NodeDrawerData, + type GraphDrawerPayload, + type SourceRange, + getSourceRange, +} from './ast/ast-to-graph'; +export { + applyDagreOverviewLayout, + applyDagreDetailLayout, +} from './ast/graph-layout'; +export { findPathEdges } from './ast/graph-path'; +export { findTopicBlock, type AgentScriptAST } from './ast/ast-utils'; + +// Tokens / visual config +export { GRAPH } from './tokens/graph-tokens'; +export { + getBlockTypeConfig, + type BlockTypeConfig, +} from './tokens/block-type-config'; + +// Context (for hosts that want to configure callbacks outside ) +export { + GraphContextProvider, + useGraphContext, + type ActionClickPayload, + type ConditionalClickPayload, + type GraphContextValue, +} from './context/GraphContext'; + +// Registries (for hosts rendering their own React Flow) +export { graphNodeTypes } from './components/nodes'; +export { graphEdgeTypes } from './components/edges'; + +// Reusable UI bits +export { DiagnosticHoverCard } from './components/nodes/DiagnosticHoverCard'; diff --git a/apps/ui/src/lib/block-type-config.tsx b/packages/graph-ui/src/tokens/block-type-config.tsx similarity index 100% rename from apps/ui/src/lib/block-type-config.tsx rename to packages/graph-ui/src/tokens/block-type-config.tsx diff --git a/apps/ui/src/lib/graph-tokens.ts b/packages/graph-ui/src/tokens/graph-tokens.ts similarity index 100% rename from apps/ui/src/lib/graph-tokens.ts rename to packages/graph-ui/src/tokens/graph-tokens.ts diff --git a/packages/graph-ui/src/utils.ts b/packages/graph-ui/src/utils.ts new file mode 100644 index 0000000..e84921e --- /dev/null +++ b/packages/graph-ui/src/utils.ts @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2026, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * For full license text, see the LICENSE file in the repo root or https://www.apache.org/licenses/LICENSE-2.0 + */ + +import { clsx, type ClassValue } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/packages/graph-ui/tsconfig.json b/packages/graph-ui/tsconfig.json new file mode 100644 index 0000000..18d52a7 --- /dev/null +++ b/packages/graph-ui/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "noEmit": true, + "strict": false, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "exclude": ["node_modules"] +} diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index 988deeb..6fb1caa 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) ## [Unreleased] +## [2.5.0] - 2026-05-22 + +### Added + +- Agent Script flow preview. Open a `.agent` file and click the preview icon in the editor title (or use the "Agent Script: Open Preview to the Side" command) to render the agent's topic flow as a node graph. Live-updates on document changes; click a node to jump to its source range in the editor. + ## [2.4.0] - 2026-5-1 ### Added diff --git a/packages/vscode/esbuild.mjs b/packages/vscode/esbuild.mjs index 6c2cc76..a01f0b7 100644 --- a/packages/vscode/esbuild.mjs +++ b/packages/vscode/esbuild.mjs @@ -16,9 +16,11 @@ */ import * as esbuild from 'esbuild'; +import { spawnSync } from 'child_process'; import { copyFileSync, cpSync, + existsSync, mkdirSync, readFileSync, rmSync, @@ -65,10 +67,36 @@ const serverBuild = { external: ['vscode', 'tree-sitter', '@agentscript/parser-tree-sitter'], }; +/** + * Ensure the webview output exists and copy flow.html into dist/webview/. + * The webview build is owned by @agentscript/vscode-webview (turbo invokes + * it as a workspace dep). If webview/dist/flow.html is missing, run a + * one-shot vite build here as a fallback so esbuild.mjs can be run directly. + */ +function syncWebview() { + const src = join(__dirname, 'webview', 'dist', 'flow.html'); + if (!existsSync(src)) { + console.log('webview/dist/flow.html not found — running vite build...'); + const result = spawnSync( + 'pnpm', + ['--filter', '@agentscript/vscode-webview', 'build'], + { cwd: join(__dirname, '../..'), stdio: 'inherit' } + ); + if (result.status !== 0) { + throw new Error('Webview build failed'); + } + } + const destDir = join(__dirname, 'dist', 'webview'); + mkdirSync(destDir, { recursive: true }); + copyFileSync(src, join(destDir, 'flow.html')); + console.log(' ✓ dist/webview/flow.html copied'); +} + async function build() { if (watch) { const extCtx = await esbuild.context(extensionBuild); const srvCtx = await esbuild.context(serverBuild); + syncWebview(); await Promise.all([extCtx.watch(), srvCtx.watch()]); console.log('Watching for changes...'); } else { @@ -76,6 +104,7 @@ async function build() { esbuild.build(extensionBuild), esbuild.build(serverBuild), ]); + syncWebview(); } } diff --git a/packages/vscode/package.json b/packages/vscode/package.json index b326a11..dfd27ab 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -2,7 +2,7 @@ "name": "@agentscript/vscode", "displayName": "Agent Script", "description": "VS Code extension for Agent Script language support", - "version": "2.4.0", + "version": "2.5.0", "private": true, "publisher": "Salesforce", "license": "Apache-2.0", @@ -22,6 +22,7 @@ }, "devDependencies": { "@agentscript/lsp-server": "workspace:*", + "@agentscript/vscode-webview": "workspace:*", "@types/vscode": "^1.85.0", "esbuild": "^0.25.0", "typescript": "^5.8.3", @@ -116,6 +117,47 @@ "editor.bracketPairColorization.enabled": false } }, + "commands": [ + { + "command": "agentscript.flow.showPreview", + "title": "Agent Script: Open Preview", + "category": "Agent Script" + }, + { + "command": "agentscript.flow.showPreviewToSide", + "title": "Agent Script: Open Preview to the Side", + "category": "Agent Script", + "icon": "$(open-preview)" + }, + { + "command": "agentscript.flow.showSource", + "title": "Agent Script: Show Source", + "category": "Agent Script" + } + ], + "menus": { + "editor/title": [ + { + "command": "agentscript.flow.showPreviewToSide", + "when": "resourceExtname == .agent", + "group": "navigation" + } + ], + "commandPalette": [ + { + "command": "agentscript.flow.showPreview", + "when": "resourceExtname == .agent" + }, + { + "command": "agentscript.flow.showPreviewToSide", + "when": "resourceExtname == .agent" + }, + { + "command": "agentscript.flow.showSource", + "when": "activeWebviewPanelId == 'agentscript.flow.preview'" + } + ] + }, "configuration": { "title": "AgentScript", "properties": { diff --git a/packages/vscode/scripts/build-all.mjs b/packages/vscode/scripts/build-all.mjs new file mode 100644 index 0000000..4bc1ea4 --- /dev/null +++ b/packages/vscode/scripts/build-all.mjs @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2026, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * For full license text, see the LICENSE file in the repo root or https://www.apache.org/licenses/LICENSE-2.0 + */ + +/** + * One-shot build for the VS Code extension and everything it needs to run. + * + * Ensures upstream workspace deps are built (via turbo), then runs the + * extension's esbuild step which bundles extension.js, server.mjs, and the + * webview (flow.html) into dist/. Use this before F5 or `code --extensionDevelopmentPath`. + */ +import { spawnSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = join(__dirname, '..', '..', '..'); +const vscodeDir = join(repoRoot, 'packages', 'vscode'); + +function run(cmd, args, opts = {}) { + console.log(`$ ${cmd} ${args.join(' ')}`); + const result = spawnSync(cmd, args, { + stdio: 'inherit', + cwd: opts.cwd ?? repoRoot, + }); + if (result.status !== 0 && !opts.allowFail) { + console.error(`\nCommand failed with exit code ${result.status}`); + process.exit(result.status ?? 1); + } + return result.status ?? 0; +} + +// 1. Build every workspace dep of @agentscript/vscode (includes +// @agentscript/vscode-webview because it's declared as a workspace +// devDep). The `^...` selector means "all deps of the target, but +// not the target itself" — the target (extension.js + server.mjs) is +// built by step 2 via esbuild.mjs. +// +// Always invoke the build. Turbo's per-package content hash cache +// makes a no-op run fast when nothing changed, and an existsSync +// skip would happily serve a stale dist/ if source changed since +// the last build, producing an extension that silently runs old code. +run('pnpm', ['--filter', '@agentscript/vscode^...', 'run', 'build']); + +// 2. Build the extension + server + copy webview flow.html into dist/webview/. +run('node', ['esbuild.mjs'], { cwd: vscodeDir }); + +// 3. Sanity-check final outputs so F5 doesn't fail mysteriously. +const required = [ + join(vscodeDir, 'dist', 'extension.js'), + join(vscodeDir, 'dist', 'server.mjs'), + join(vscodeDir, 'dist', 'webview', 'flow.html'), +]; +const missing = required.filter(p => !existsSync(p)); +if (missing.length) { + console.error('\nBuild completed but these outputs are missing:'); + for (const p of missing) console.error(` - ${p}`); + process.exit(1); +} + +console.log( + '\n✓ Build complete. Press F5 to launch the Extension Development Host.' +); diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index 4bc3142..9a6269f 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -20,6 +20,7 @@ import type { ServerOptions, } from 'vscode-languageclient/node.js'; import { getCoreExtension } from './coreExtensionUtils'; +import { registerFlowPreviewCommands } from './previewFlow'; import { setTelemetryService, getTelemetryService } from './telemetry'; const DIALECT_PATTERN = /^#\s*@dialect:/; @@ -137,6 +138,8 @@ let restartGeneration = 0; export function activate(context: vscode.ExtensionContext): void { const extensionHRStart = process.hrtime(); + context.subscriptions.push(registerFlowPreviewCommands(context)); + // Register document listeners before auto-detect so the re-open // triggered by setTextDocumentLanguage is caught for untitled files. context.subscriptions.push( diff --git a/packages/vscode/src/previewFlow.ts b/packages/vscode/src/previewFlow.ts new file mode 100644 index 0000000..63e6df2 --- /dev/null +++ b/packages/vscode/src/previewFlow.ts @@ -0,0 +1,264 @@ +/* + * Copyright (c) 2026, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * For full license text, see the LICENSE file in the repo root or https://www.apache.org/licenses/LICENSE-2.0 + */ + +/** + * Agent Script flow preview commands. + * + * Opens a side webview panel that renders the agent graph using the shared + * @agentscript/graph-ui component. Live-updates on document changes with + * 250ms debounce. Follows Markdown Preview UX with three commands. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as vscode from 'vscode'; + +const FLOW_VIEW_TYPE = 'agentscript.flow.preview'; +const DEBOUNCE_MS = 250; + +const COMMAND_SHOW_PREVIEW = 'agentscript.flow.showPreview'; +const COMMAND_SHOW_PREVIEW_TO_SIDE = 'agentscript.flow.showPreviewToSide'; +const COMMAND_SHOW_SOURCE = 'agentscript.flow.showSource'; + +interface FlowPanelState { + uri: string; +} + +class FlowPanelManager { + private readonly panels = new Map(); + private readonly debouncers = new Map(); + + constructor(private readonly context: vscode.ExtensionContext) { + context.subscriptions.push( + vscode.workspace.onDidChangeTextDocument(e => { + this.scheduleUpdate(e.document); + }) + ); + } + + public async open(uri: vscode.Uri, column: vscode.ViewColumn): Promise { + const key = uri.toString(); + const existing = this.panels.get(key); + if (existing) { + existing.reveal(column, true); + return; + } + + const panel = vscode.window.createWebviewPanel( + FLOW_VIEW_TYPE, + `Preview ${path.basename(uri.fsPath)}`, + { viewColumn: column, preserveFocus: true }, + { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [ + vscode.Uri.joinPath(this.context.extensionUri, 'dist', 'webview'), + ], + } + ); + + this.registerPanel(panel, uri); + + const doc = await vscode.workspace.openTextDocument(uri); + this.postSource(panel, doc); + } + + public async restore( + panel: vscode.WebviewPanel, + state: FlowPanelState + ): Promise { + try { + const uri = vscode.Uri.parse(state.uri); + this.registerPanel(panel, uri); + const doc = await vscode.workspace.openTextDocument(uri); + this.postSource(panel, doc); + } catch { + panel.dispose(); + } + } + + public async showSource(): Promise { + for (const [key, panel] of this.panels) { + if (panel.active) { + const uri = vscode.Uri.parse(key); + await vscode.window.showTextDocument(uri, { + viewColumn: vscode.ViewColumn.Beside, + preserveFocus: false, + }); + return; + } + } + } + + private registerPanel(panel: vscode.WebviewPanel, uri: vscode.Uri): void { + const key = uri.toString(); + this.panels.set(key, panel); + + panel.webview.html = this.getHtml(); + panel.webview.onDidReceiveMessage(msg => this.handleMessage(msg, uri)); + + panel.onDidDispose(() => { + this.panels.delete(key); + const t = this.debouncers.get(key); + if (t) { + clearTimeout(t); + this.debouncers.delete(key); + } + }); + } + + private scheduleUpdate(doc: vscode.TextDocument): void { + const key = doc.uri.toString(); + const panel = this.panels.get(key); + if (!panel) return; + const existing = this.debouncers.get(key); + if (existing) clearTimeout(existing); + this.debouncers.set( + key, + setTimeout(() => { + this.debouncers.delete(key); + this.postSource(panel, doc); + }, DEBOUNCE_MS) + ); + } + + private postSource( + panel: vscode.WebviewPanel, + doc: vscode.TextDocument + ): void { + void panel.webview.postMessage({ + type: 'source', + uri: doc.uri.toString(), + text: doc.getText(), + version: doc.version, + }); + } + + private async handleMessage( + msg: { type: string; [k: string]: unknown }, + uri: vscode.Uri + ): Promise { + if (msg.type === 'ready') { + try { + const doc = await vscode.workspace.openTextDocument(uri); + const panel = this.panels.get(uri.toString()); + if (panel) this.postSource(panel, doc); + } catch { + // file may have been deleted + } + return; + } + if (msg.type === 'goto-source') { + const range = msg.range as + | { + startLine: number; + startCol: number; + endLine: number; + endCol: number; + } + | undefined; + if (!range) return; + try { + const doc = await vscode.workspace.openTextDocument(uri); + const selection = new vscode.Range( + range.startLine, + range.startCol, + range.endLine, + range.endCol + ); + await vscode.window.showTextDocument(doc, { + viewColumn: vscode.ViewColumn.One, + preserveFocus: false, + selection, + }); + } catch { + // file may have been deleted + } + } + } + + private getHtml(): string { + const htmlPath = path.join( + this.context.extensionPath, + 'dist', + 'webview', + 'flow.html' + ); + let html = fs.readFileSync(htmlPath, 'utf8'); + const vscodeScript = ` + + `; + html = html.replace('', `${vscodeScript}`); + return html; + } +} + +class FlowPanelSerializer implements vscode.WebviewPanelSerializer { + constructor(private readonly manager: FlowPanelManager) {} + async deserializeWebviewPanel( + panel: vscode.WebviewPanel, + state: unknown + ): Promise { + const s = (state ?? {}) as Partial; + if (!s.uri) { + panel.dispose(); + return; + } + await this.manager.restore(panel, s as FlowPanelState); + } +} + +export function registerFlowPreviewCommands( + context: vscode.ExtensionContext +): vscode.Disposable { + const manager = new FlowPanelManager(context); + + const resolveUri = (uri?: vscode.Uri): vscode.Uri | undefined => + uri ?? vscode.window.activeTextEditor?.document.uri; + + const disposables: vscode.Disposable[] = []; + + disposables.push( + vscode.commands.registerCommand( + COMMAND_SHOW_PREVIEW, + async (uri?: vscode.Uri) => { + const resolved = resolveUri(uri); + if (!resolved) return; + await manager.open(resolved, vscode.ViewColumn.Active); + } + ) + ); + + disposables.push( + vscode.commands.registerCommand( + COMMAND_SHOW_PREVIEW_TO_SIDE, + async (uri?: vscode.Uri) => { + const resolved = resolveUri(uri); + if (!resolved) return; + await manager.open(resolved, vscode.ViewColumn.Beside); + } + ) + ); + + disposables.push( + vscode.commands.registerCommand(COMMAND_SHOW_SOURCE, async () => { + await manager.showSource(); + }) + ); + + disposables.push( + vscode.window.registerWebviewPanelSerializer( + FLOW_VIEW_TYPE, + new FlowPanelSerializer(manager) + ) + ); + + return vscode.Disposable.from(...disposables); +} diff --git a/packages/vscode/webview/flow.html b/packages/vscode/webview/flow.html new file mode 100644 index 0000000..0d628e2 --- /dev/null +++ b/packages/vscode/webview/flow.html @@ -0,0 +1,16 @@ + + + + + + + Agent Script Preview + + +
    + + + diff --git a/packages/vscode/webview/package.json b/packages/vscode/webview/package.json new file mode 100644 index 0000000..a260fe5 --- /dev/null +++ b/packages/vscode/webview/package.json @@ -0,0 +1,32 @@ +{ + "name": "@agentscript/vscode-webview", + "private": true, + "version": "0.0.0", + "license": "Apache-2.0", + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@agentscript/agentforce": "workspace:*", + "@agentscript/agentforce-dialect": "workspace:*", + "@agentscript/graph-ui": "workspace:*", + "@xyflow/react": "^12.10.0", + "@dagrejs/dagre": "^2.0.4", + "lucide-react": "^0.545.0", + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.17", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", + "@vitejs/plugin-react": "^5.1.0", + "tailwindcss": "^4.1.17", + "typescript": "^5.8.3", + "vite": "^7.2.2", + "vite-plugin-singlefile": "^2.3.0" + } +} diff --git a/packages/vscode/webview/src/FlowApp.tsx b/packages/vscode/webview/src/FlowApp.tsx new file mode 100644 index 0000000..b7a2d4d --- /dev/null +++ b/packages/vscode/webview/src/FlowApp.tsx @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2026, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * For full license text, see the LICENSE file in the repo root or https://www.apache.org/licenses/LICENSE-2.0 + */ + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { ChevronLeft } from 'lucide-react'; +import { parse } from '@agentscript/agentforce'; +import { Graph, type AgentScriptAST } from '@agentscript/graph-ui'; + +interface SourceMessage { + type: 'source'; + uri: string; + text: string; + version: number; +} + +interface VscodeApi { + postMessage(msg: unknown): void; + setState(state: unknown): void; + getState(): T | undefined; +} + +declare global { + interface Window { + acquireVsCodeApi?: () => VscodeApi; + } +} + +function useVscodeApi(): VscodeApi | null { + const [api] = useState(() => { + if (typeof window === 'undefined') return null; + return window.acquireVsCodeApi?.() ?? null; + }); + return api; +} + +type Theme = 'light' | 'dark'; + +function detectTheme(): Theme { + if (typeof document === 'undefined') return 'light'; + const cls = document.body.className; + if (cls.includes('vscode-dark') || cls.includes('vscode-high-contrast')) { + return 'dark'; + } + return 'light'; +} + +function useTheme(): Theme { + const [theme, setTheme] = useState(detectTheme); + useEffect(() => { + const observer = new MutationObserver(() => setTheme(detectTheme())); + observer.observe(document.body, { + attributes: true, + attributeFilter: ['class'], + }); + return () => observer.disconnect(); + }, []); + return theme; +} + +export function FlowApp() { + const api = useVscodeApi(); + const theme = useTheme(); + const [source, setSource] = useState(''); + const [topicId, setTopicId] = useState(); + + useEffect(() => { + if (!api) return; + const handler = (event: MessageEvent) => { + if (event.data.type === 'source') { + setSource(event.data.text); + } + }; + window.addEventListener('message', handler); + api.postMessage({ type: 'ready' }); + return () => window.removeEventListener('message', handler); + }, [api]); + + const ast = useMemo(() => { + if (!source) return null; + try { + const parsed = parse(source); + return parsed.ast as unknown as AgentScriptAST; + } catch { + return null; + } + }, [source]); + + const handleTopicOpen = useCallback((topicName: string) => { + setTopicId(topicName); + }, []); + + const handleBack = useCallback(() => setTopicId(undefined), []); + + const handleNodeClick = useCallback( + (payload: { data: { sourceRange?: unknown } }) => { + const range = payload.data.sourceRange; + if (!range) return; + api?.postMessage({ type: 'goto-source', range }); + }, + [api] + ); + + const handleActionClick = useCallback( + (payload: { sourceRange?: unknown }) => { + const range = payload.sourceRange; + if (!range) return; + api?.postMessage({ type: 'goto-source', range }); + }, + [api] + ); + + return ( +
    + + + {topicId ? ( +
    + + Topic: {topicId} +
    + ) : null} +
    + ); +} diff --git a/packages/vscode/webview/src/flow.css b/packages/vscode/webview/src/flow.css new file mode 100644 index 0000000..35cf885 --- /dev/null +++ b/packages/vscode/webview/src/flow.css @@ -0,0 +1,11 @@ +@import 'tailwindcss'; + +@source '../../../graph-ui/src/**/*.{ts,tsx}'; + +html, +body, +#root { + height: 100%; + margin: 0; + padding: 0; +} diff --git a/packages/vscode/webview/src/main.tsx b/packages/vscode/webview/src/main.tsx new file mode 100644 index 0000000..6db1380 --- /dev/null +++ b/packages/vscode/webview/src/main.tsx @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2026, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * For full license text, see the LICENSE file in the repo root or https://www.apache.org/licenses/LICENSE-2.0 + */ + +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { FlowApp } from './FlowApp'; +import './flow.css'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +); diff --git a/packages/vscode/webview/tsconfig.json b/packages/vscode/webview/tsconfig.json new file mode 100644 index 0000000..01d975d --- /dev/null +++ b/packages/vscode/webview/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "strict": false, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/vscode/webview/vite.config.ts b/packages/vscode/webview/vite.config.ts new file mode 100644 index 0000000..eeec619 --- /dev/null +++ b/packages/vscode/webview/vite.config.ts @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * For full license text, see the LICENSE file in the repo root or https://www.apache.org/licenses/LICENSE-2.0 + */ + +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import tailwindcss from '@tailwindcss/vite'; +import { viteSingleFile } from 'vite-plugin-singlefile'; + +export default defineConfig({ + plugins: [react(), tailwindcss(), viteSingleFile()], + build: { + outDir: 'dist', + emptyOutDir: true, + rollupOptions: { + input: 'flow.html', + output: { + inlineDynamicImports: true, + manualChunks: undefined, + }, + }, + assetsInlineLimit: 100000000, + chunkSizeWarningLimit: 100000000, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2271c91..57b8927 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -147,6 +147,9 @@ importers: '@agentscript/compiler': specifier: workspace:* version: link:../../packages/compiler + '@agentscript/graph-ui': + specifier: workspace:* + version: link:../../packages/graph-ui '@agentscript/language': specifier: workspace:* version: link:../../packages/language @@ -478,6 +481,49 @@ importers: specifier: ^2.8.3 version: 2.8.3 + packages/graph-ui: + dependencies: + '@agentscript/agentforce-dialect': + specifier: workspace:* + version: link:../../dialect/agentforce + '@agentscript/language': + specifier: workspace:* + version: link:../language + '@agentscript/types': + specifier: workspace:* + version: link:../types + '@dagrejs/dagre': + specifier: ^2.0.4 + version: 2.0.4 + '@xyflow/react': + specifier: ^12.10.0 + version: 12.10.0(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + clsx: + specifier: ^2.1.1 + version: 2.1.1 + lucide-react: + specifier: ^0.545.0 + version: 0.545.0(react@19.2.3) + react: + specifier: ^19.0.0 + version: 19.2.3 + react-dom: + specifier: ^19.0.0 + version: 19.2.3(react@19.2.3) + tailwind-merge: + specifier: ^3.3.1 + version: 3.4.0 + devDependencies: + '@types/react': + specifier: ^19.2.2 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.2 + version: 19.2.3(@types/react@19.2.14) + typescript: + specifier: ^5.8.3 + version: 5.9.3 + packages/language: dependencies: '@agentscript/types': @@ -672,6 +718,9 @@ importers: '@agentscript/lsp-server': specifier: workspace:* version: link:../lsp-server + '@agentscript/vscode-webview': + specifier: workspace:* + version: link:webview '@types/vscode': specifier: ^1.85.0 version: 1.110.0 @@ -685,6 +734,58 @@ importers: specifier: ^5.8.3 version: 5.9.3 + packages/vscode/webview: + dependencies: + '@agentscript/agentforce': + specifier: workspace:* + version: link:../../agentforce + '@agentscript/agentforce-dialect': + specifier: workspace:* + version: link:../../../dialect/agentforce + '@agentscript/graph-ui': + specifier: workspace:* + version: link:../../graph-ui + '@dagrejs/dagre': + specifier: ^2.0.4 + version: 2.0.4 + '@xyflow/react': + specifier: ^12.10.0 + version: 12.10.0(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + lucide-react: + specifier: ^0.545.0 + version: 0.545.0(react@19.2.3) + react: + specifier: ^19.2.0 + version: 19.2.3 + react-dom: + specifier: ^19.2.0 + version: 19.2.3(react@19.2.3) + devDependencies: + '@tailwindcss/vite': + specifier: ^4.1.17 + version: 4.1.18(vite@7.3.2(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)) + '@types/react': + specifier: ^19.2.2 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.2 + version: 19.2.3(@types/react@19.2.14) + '@vitejs/plugin-react': + specifier: ^5.1.0 + version: 5.1.2(vite@7.3.2(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)) + tailwindcss: + specifier: ^4.1.17 + version: 4.1.18 + typescript: + specifier: ^5.8.3 + version: 5.9.3 + vite: + specifier: ^7.3.2 + version: 7.3.2(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) + vite-plugin-singlefile: + specifier: ^2.3.0 + version: 2.3.3(rollup@4.60.1)(vite@7.3.2(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)) + packages: '@ai-sdk/gateway@2.0.27': @@ -9495,6 +9596,16 @@ packages: engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true + vite-plugin-singlefile@2.3.3: + resolution: {integrity: sha512-XVnGH0QzbOa8fxRSsHdCarVN1BSBXNi7uLMQYlrGRN5apdHkk62XQWRJhVever0lnfuyBkwn+kvVChdm/OoOUg==} + engines: {node: '>18.0.0'} + peerDependencies: + rollup: ^4.59.0 + vite: ^7.3.2 + peerDependenciesMeta: + rollup: + optional: true + vite@7.3.2: resolution: {integrity: sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -14253,6 +14364,10 @@ snapshots: '@types/range-parser@1.2.7': {} + '@types/react-dom@19.2.3(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + '@types/react-dom@19.2.3(@types/react@19.2.9)': dependencies: '@types/react': 19.2.9 @@ -14764,6 +14879,17 @@ snapshots: '@xtuc/long@4.2.2': {} + '@xyflow/react@12.10.0(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@xyflow/system': 0.0.74 + classcat: 5.0.5 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + zustand: 4.5.7(@types/react@19.2.14)(react@19.2.3) + transitivePeerDependencies: + - '@types/react' + - immer + '@xyflow/react@12.10.0(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@xyflow/system': 0.0.74 @@ -20576,6 +20702,13 @@ snapshots: - tsx - yaml + vite-plugin-singlefile@2.3.3(rollup@4.60.1)(vite@7.3.2(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)): + dependencies: + micromatch: 4.0.8 + vite: 7.3.2(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) + optionalDependencies: + rollup: 4.60.1 + vite@7.3.2(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3): dependencies: esbuild: 0.27.4 @@ -20990,6 +21123,13 @@ snapshots: zod@4.3.6: {} + zustand@4.5.7(@types/react@19.2.14)(react@19.2.3): + dependencies: + use-sync-external-store: 1.6.0(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + react: 19.2.3 + zustand@4.5.7(@types/react@19.2.9)(react@19.2.3): dependencies: use-sync-external-store: 1.6.0(react@19.2.3) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7c45e18..e470fbf 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,6 @@ packages: - 'packages/*' + - 'packages/vscode/webview' - 'dialect/*' - 'apps/*'