From d806763db353d1dad6f76f99d1a278ad29fc6a68 Mon Sep 17 00:00:00 2001 From: Marcelino Llano Date: Mon, 4 May 2026 19:47:53 +0100 Subject: [PATCH 1/6] feat(graph-ui): add shared graph rendering package Extracts AgentScript graph rendering from apps/ui into a reusable package so both apps/ui and the VS Code webview can render the same React Flow graph without duplicating nodes, edges, layout, and AST transforms. - AST to graph transforms (overview + topic detail) - Dagre-based hierarchical layout - Path finding for selection highlighting - 13 node types (start, topic, action, conditional, phase, llm, etc.) - 3 edge types (animated, conditional, loop-back) - Block type / semantic color tokens - GraphContext decouples host state (highlighting, action clicks) from the shared components, replacing the prior zustand store dependency --- packages/graph-ui/package.json | 40 + packages/graph-ui/src/Graph.tsx | 244 +++ packages/graph-ui/src/ast/ast-to-graph.ts | 1406 +++++++++++++++++ packages/graph-ui/src/ast/ast-utils.ts | 23 + packages/graph-ui/src/ast/graph-layout.ts | 874 ++++++++++ packages/graph-ui/src/ast/graph-path.ts | 87 + .../src/components/edges/AnimatedEdge.tsx | 139 ++ .../src/components/edges/ConditionalEdge.tsx | 150 ++ .../src/components/edges/LoopBackEdge.tsx | 110 ++ .../graph-ui/src/components/edges/index.ts | 19 + .../src/components/nodes/ActionNode.tsx | 45 + .../nodes/BuildInstructionsNode.tsx | 31 + .../components/nodes/CompoundTopicNode.tsx | 81 + .../src/components/nodes/ConditionalNode.tsx | 82 + .../components/nodes/DiagnosticHoverCard.tsx | 103 ++ .../graph-ui/src/components/nodes/LlmNode.tsx | 83 + .../src/components/nodes/NodeHandles.tsx | 212 +++ .../src/components/nodes/PhaseNode.tsx | 154 ++ .../components/nodes/ReasoningGroupNode.tsx | 158 ++ .../graph-ui/src/components/nodes/RunNode.tsx | 39 + .../graph-ui/src/components/nodes/SetNode.tsx | 41 + .../src/components/nodes/StartNode.tsx | 42 + .../src/components/nodes/TemplateNode.tsx | 39 + .../src/components/nodes/TopicNode.tsx | 71 + .../src/components/nodes/TransitionNode.tsx | 44 + .../src/components/nodes/diagnosticBorder.ts | 36 + .../graph-ui/src/components/nodes/index.ts | 54 + .../graph-ui/src/context/GraphContext.tsx | 50 + packages/graph-ui/src/index.ts | 53 + .../graph-ui/src/tokens/block-type-config.tsx | 154 ++ packages/graph-ui/src/tokens/graph-tokens.ts | 47 + packages/graph-ui/src/utils.ts | 13 + packages/graph-ui/tsconfig.json | 23 + 33 files changed, 4747 insertions(+) create mode 100644 packages/graph-ui/package.json create mode 100644 packages/graph-ui/src/Graph.tsx create mode 100644 packages/graph-ui/src/ast/ast-to-graph.ts create mode 100644 packages/graph-ui/src/ast/ast-utils.ts create mode 100644 packages/graph-ui/src/ast/graph-layout.ts create mode 100644 packages/graph-ui/src/ast/graph-path.ts create mode 100644 packages/graph-ui/src/components/edges/AnimatedEdge.tsx create mode 100644 packages/graph-ui/src/components/edges/ConditionalEdge.tsx create mode 100644 packages/graph-ui/src/components/edges/LoopBackEdge.tsx create mode 100644 packages/graph-ui/src/components/edges/index.ts create mode 100644 packages/graph-ui/src/components/nodes/ActionNode.tsx create mode 100644 packages/graph-ui/src/components/nodes/BuildInstructionsNode.tsx create mode 100644 packages/graph-ui/src/components/nodes/CompoundTopicNode.tsx create mode 100644 packages/graph-ui/src/components/nodes/ConditionalNode.tsx create mode 100644 packages/graph-ui/src/components/nodes/DiagnosticHoverCard.tsx create mode 100644 packages/graph-ui/src/components/nodes/LlmNode.tsx create mode 100644 packages/graph-ui/src/components/nodes/NodeHandles.tsx create mode 100644 packages/graph-ui/src/components/nodes/PhaseNode.tsx create mode 100644 packages/graph-ui/src/components/nodes/ReasoningGroupNode.tsx create mode 100644 packages/graph-ui/src/components/nodes/RunNode.tsx create mode 100644 packages/graph-ui/src/components/nodes/SetNode.tsx create mode 100644 packages/graph-ui/src/components/nodes/StartNode.tsx create mode 100644 packages/graph-ui/src/components/nodes/TemplateNode.tsx create mode 100644 packages/graph-ui/src/components/nodes/TopicNode.tsx create mode 100644 packages/graph-ui/src/components/nodes/TransitionNode.tsx create mode 100644 packages/graph-ui/src/components/nodes/diagnosticBorder.ts create mode 100644 packages/graph-ui/src/components/nodes/index.ts create mode 100644 packages/graph-ui/src/context/GraphContext.tsx create mode 100644 packages/graph-ui/src/index.ts create mode 100644 packages/graph-ui/src/tokens/block-type-config.tsx create mode 100644 packages/graph-ui/src/tokens/graph-tokens.ts create mode 100644 packages/graph-ui/src/utils.ts create mode 100644 packages/graph-ui/tsconfig.json diff --git a/packages/graph-ui/package.json b/packages/graph-ui/package.json new file mode 100644 index 00000000..c8c9f82d --- /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 00000000..4c2d0b6d --- /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/packages/graph-ui/src/ast/ast-to-graph.ts b/packages/graph-ui/src/ast/ast-to-graph.ts new file mode 100644 index 00000000..ba7c60e9 --- /dev/null +++ b/packages/graph-ui/src/ast/ast-to-graph.ts @@ -0,0 +1,1406 @@ +/* + * 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 + */ + +/** + * AST to Graph Data Transformer + * + * Converts AgentScript AST into React Flow-compatible node/edge arrays + * for the Graph view. Two modes: + * - Overview: topics as nodes, transitions as edges + * - Topic Detail: compound sections, actions, conditionals, transitions + */ + +import type { Node, Edge } from '@xyflow/react'; +import { + collectDiagnostics, + decomposeAtMemberExpression, + isNamedMap, + NamedMap, + type Diagnostic, + type Statement, +} from '@agentscript/language'; +import { findTopicBlock, type AgentScriptAST } from './ast-utils'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type GraphNodeType = + | 'start' + | 'start-agent' + | 'topic' + | 'action' + | 'compound-topic' + | 'conditional' + | 'transition' + | 'run' + | 'set' + | 'template' + | 'phase' + | 'phase-label' + | 'llm' + | 'build-instructions' + | 'reasoning-group'; + +export type PhaseType = + | 'topic-header' + | 'before_reasoning' + | 'after_reasoning' + | 'before_reasoning_iteration'; + +/** Well-known group container IDs for post-layout positioning. */ +export const GROUP_IDS = { + beforeReasoning: 'group-before-reasoning', + reasoningLoop: 'group-reasoning-loop', + afterReasoning: 'group-after-reasoning', +} as const; + +export interface GraphNodeData extends Record { + nodeType: GraphNodeType; + label: string; + subtitle?: string; + blockType: string; + isStartAgent?: boolean; + topicName?: string; + conditionText?: string; + /** Short human-readable label derived from the condition (for compact display). */ + conditionLabel?: string; + transitionTarget?: string; + sections?: string[]; + actionNames?: string[]; + /** Raw action map keys (parallel to actionNames) for AST lookup. */ + actionKeys?: string[]; + diagnostics?: Diagnostic[]; + /** Phase type for phase/phase-label nodes */ + phaseType?: PhaseType; + /** Which group container this node belongs to (for post-layout grouping). */ + groupId?: string; + /** True for nodes on the main execution pipeline (spine). */ + isSpine?: boolean; + /** Ordering index on the spine (0 = first). Used by deterministic layout. */ + spineIndex?: number; + /** True when a container/phase exists but has no child statements. */ + isEmpty?: boolean; + /** Set of handle IDs that have edges connected (populated after layout). */ + connectedHandles?: ReadonlySet; + /** Horizontal offset from container left edge to spine center (for group handle positioning). */ + spineOffsetX?: number; +} + +/** Data attached to conditional edges for the drawer. */ +export interface ConditionalEdgeData extends Record { + conditionText: string; + sourceTopicName: string; + conditionalKey: string; +} + +/** Data for the action detail drawer. */ +export interface ActionDrawerData { + actionDisplayName: string; + actionIndex: number; + topicName?: string; +} + +/** Data for the node detail drawer (any clickable graph node). */ +export interface NodeDrawerData { + nodeId: string; + nodeType: GraphNodeType; + label: string; + subtitle?: string; + topicName?: string; + conditionText?: string; + conditionLabel?: string; + transitionTarget?: string; + phaseType?: PhaseType; + actionNames?: string[]; + actionKeys?: string[]; + isEmpty?: boolean; +} + +/** Discriminated union for graph drawer content types. */ +export type GraphDrawerPayload = + | { type: 'conditional'; data: ConditionalEdgeData } + | { type: 'action'; data: ActionDrawerData } + | { type: 'node'; data: NodeDrawerData }; + +export type GraphNode = Node; +export type GraphEdge = Edge; + +interface TransitionInfo { + targetTopicName: string; + conditionText?: string; + /** Which branch of a conditional this transition is in */ + branch?: 'if' | 'else'; + /** Groups if+else branches of the same conditional (uses the condition text) */ + conditionalKey?: string; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Derive a short human-readable label from a condition expression. + * E.g. `@variables.checked_loyalty_tier == False` → `Checked Loyalty Tier?` + */ +function abbreviateCondition(condText: string): string { + // Try to extract a variable name from @variables.xxx or @xxx.yyy patterns + const varMatch = condText.match(/@\w+\.(\w+)/); + if (varMatch) { + const varName = varMatch[1]; + // Convert snake_case to Title Case and append "?" + const label = varName + .split('_') + .map(w => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' '); + return `${label}?`; + } + // Fall back to truncated text + if (condText.length > 18) { + return `${condText.slice(0, 18)}...`; + } + return condText; +} + +function toDisplayLabel(name: string): string { + return name + .split('_') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); +} + +/** Get the label from a topic block, falling back to formatted name. */ +function getTopicLabel(block: Record, name: string): string { + const label = block.label as { value?: string } | undefined; + return label?.value ?? toDisplayLabel(name); +} + +/** + * Resolve a ToClause target expression to a topic name. + * Handles `@topic.name` MemberExpression patterns. + */ +function resolveTransitionTarget(expr: unknown): string | null { + const decomposed = decomposeAtMemberExpression(expr); + if (decomposed && decomposed.namespace === 'topic') { + return decomposed.property; + } + return null; +} + +/** + * Recursively walk statement arrays to extract all transition targets. + * Handles TransitionStatement (with ToClause children) and IfStatement branches. + */ +function extractTransitions( + statements: Statement[], + parentCondition?: string, + parentBranch?: 'if' | 'else', + parentConditionalKey?: string +): TransitionInfo[] { + const transitions: TransitionInfo[] = []; + + for (const stmt of statements) { + if (stmt.__kind === 'TransitionStatement') { + const transition = stmt as unknown as { clauses: Statement[] }; + for (const clause of transition.clauses) { + if (clause.__kind === 'ToClause') { + const toClause = clause as unknown as { target: unknown }; + const target = resolveTransitionTarget(toClause.target); + if (target) { + transitions.push({ + targetTopicName: target, + conditionText: parentCondition, + branch: parentBranch, + conditionalKey: parentConditionalKey, + }); + } + } + } + } else if (stmt.__kind === 'IfStatement') { + const ifStmt = stmt as unknown as { + condition?: { __emit?(ctx: { indent: number }): string }; + body: Statement[]; + orelse: Statement[]; + }; + const condText = ifStmt.condition?.__emit?.({ indent: 0 }) ?? ''; + transitions.push( + ...extractTransitions(ifStmt.body, condText, 'if', condText) + ); + + if (ifStmt.orelse?.length > 0) { + if ( + ifStmt.orelse.length === 1 && + ifStmt.orelse[0].__kind === 'IfStatement' + ) { + // elif chain — recurse (each elif gets its own conditionalKey) + transitions.push(...extractTransitions(ifStmt.orelse)); + } else { + // else branch — same conditionalKey as the if + transitions.push( + ...extractTransitions(ifStmt.orelse, condText, 'else', condText) + ); + } + } + } + } + + return transitions; +} + +/** Get statements from a ProcedureValue field. */ +function getProcedureStatements(procedure: unknown): Statement[] { + if (!procedure || typeof procedure !== 'object') return []; + const proc = procedure as { statements?: Statement[] }; + return proc.statements ?? []; +} + +/** + * Extract all transitions from a topic block. + * Searches three locations: + * 1. after_reasoning.statements[] — TransitionStatement / IfStatement with ToClause + * 2. reasoning.instructions.statements[] — TransitionStatement / IfStatement with ToClause + * 3. reasoning.actions (Map) — each has statements[] with ToClause + * These are `@utils.transition` reasoning actions (e.g., go_to_identity, go_to_order) + */ +function extractAllTopicTransitions( + block: Record +): TransitionInfo[] { + const transitions: TransitionInfo[] = []; + const seen = new Set(); + + const addUnique = (infos: TransitionInfo[]) => { + for (const info of infos) { + const key = `${info.targetTopicName}:${info.conditionText ?? ''}:${info.branch ?? ''}:${info.conditionalKey ?? ''}`; + if (!seen.has(key)) { + seen.add(key); + transitions.push(info); + } + } + }; + + // 1. after_reasoning + addUnique(extractTransitions(getProcedureStatements(block.after_reasoning))); + + // 2. reasoning.instructions + const reasoning = block.reasoning as Record | undefined; + if (reasoning) { + addUnique( + extractTransitions(getProcedureStatements(reasoning.instructions)) + ); + + // 3. reasoning.actions (Map of ReasoningActionBlock) + const reasoningActions = reasoning.actions as + | NamedMap> + | undefined; + if (isNamedMap(reasoningActions)) { + for (const [, ab] of reasoningActions) { + const stmts = ab.statements as Statement[] | undefined; + if (stmts) { + addUnique(extractTransitionsFromReasoningAction(stmts)); + } + } + } + } + + // 3. before_reasoning (can also contain transitions) + addUnique(extractTransitions(getProcedureStatements(block.before_reasoning))); + + return transitions; +} + +/** + * Extract transitions from a ReasoningActionBlock's statements. + * These contain ToClause (direct target) and AvailableWhen (conditions). + */ +function extractTransitionsFromReasoningAction( + statements: Statement[] +): TransitionInfo[] { + const transitions: TransitionInfo[] = []; + for (const stmt of statements) { + if (stmt.__kind === 'ToClause') { + 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 unknown as { clauses: Statement[] }; + for (const clause of transition.clauses) { + if (clause.__kind === 'ToClause') { + const toClause = clause as unknown as { target: unknown }; + const target = resolveTransitionTarget(toClause.target); + if (target) { + transitions.push({ targetTopicName: target }); + } + } + } + } + } + return transitions; +} + +/** Get the names of all topics (both start_agent and topic). */ +function getAllTopicNames(ast: AgentScriptAST): Set { + const names = new Set(); + const startAgent = ast.start_agent as + | NamedMap> + | undefined; + const topics = ast.topic as NamedMap> | undefined; + if (isNamedMap(startAgent)) { + for (const name of startAgent.keys()) names.add(name as string); + } + if (isNamedMap(topics)) { + for (const name of topics.keys()) names.add(name as string); + } + return names; +} + +// --------------------------------------------------------------------------- +// Overview Graph +// --------------------------------------------------------------------------- + +/** + * Convert AST to an overview graph: Start → Start Agents → Topics + * with transition edges between topics. Conditional transitions use + * a dedicated edge type with the condition text as a label. + */ +export function astToOverviewGraph(ast: AgentScriptAST): { + nodes: GraphNode[]; + edges: GraphEdge[]; +} { + const nodes: GraphNode[] = []; + const edges: GraphEdge[] = []; + const validTopics = getAllTopicNames(ast); + + // Start node + nodes.push({ + id: 'start', + type: 'start', + position: { x: 0, y: 0 }, + data: { + nodeType: 'start', + label: 'Start', + blockType: 'start', + }, + }); + + /** + * Process transitions from a source topic node. + * Unconditional transitions → direct edges. + * Conditional transitions → conditional edges with condition label. + */ + function addTransitionEdges( + sourceNodeId: string, + block: Record + ) { + try { + const transitions = extractAllTopicTransitions(block); + const unconditional: TransitionInfo[] = []; + // Group conditional transitions by conditionalKey + const conditionalGroups = new Map< + string, + { ifTargets: string[]; elseTargets: string[]; condText: string } + >(); + + for (const t of transitions) { + if (!validTopics.has(t.targetTopicName)) continue; + if (t.branch && t.conditionalKey) { + let group = conditionalGroups.get(t.conditionalKey); + if (!group) { + group = { + ifTargets: [], + elseTargets: [], + condText: t.conditionalKey, + }; + conditionalGroups.set(t.conditionalKey, group); + } + const targetId = findTopicNodeId(t.targetTopicName, ast); + if (t.branch === 'if') { + group.ifTargets.push(targetId); + } else { + group.elseTargets.push(targetId); + } + } else { + unconditional.push(t); + } + } + + // Direct edges for unconditional transitions + for (const t of unconditional) { + const targetId = findTopicNodeId(t.targetTopicName, ast); + edges.push({ + id: `e-${sourceNodeId}-${targetId}`, + source: sourceNodeId, + target: targetId, + type: 'smoothstep', + }); + } + + // Conditional edges with gate-icon labels + // Derive topic name from node ID (format: "start_agent-name" or "topic-name") + const sourceTopicName = sourceNodeId.replace(/^(start_agent|topic)-/, ''); + + for (const [, group] of conditionalGroups) { + for (const targetId of group.ifTargets) { + edges.push({ + id: `e-${sourceNodeId}-if-${targetId}`, + source: sourceNodeId, + target: targetId, + type: 'conditional', + label: `If: ${group.condText}`, + markerEnd: { type: 'arrowclosed' as const, color: '#9ca3af' }, + data: { + conditionText: group.condText, + sourceTopicName, + conditionalKey: group.condText, + }, + }); + } + + for (const targetId of group.elseTargets) { + edges.push({ + id: `e-${sourceNodeId}-else-${targetId}`, + source: sourceNodeId, + target: targetId, + type: 'conditional', + label: 'Else', + markerEnd: { type: 'arrowclosed' as const, color: '#9ca3af' }, + data: { + conditionText: group.condText, + sourceTopicName, + conditionalKey: group.condText, + }, + }); + } + } + } catch { + // Skip transition extraction if AST is malformed + } + } + + // Start Agent nodes + const startAgent = ast.start_agent as + | NamedMap> + | undefined; + if (isNamedMap(startAgent)) { + for (const [name, block] of startAgent) { + const nodeId = `start_agent-${name}`; + const blockDiagnostics = collectDiagnostics(block); + nodes.push({ + id: nodeId, + type: 'start-agent', + position: { x: 0, y: 0 }, + data: { + nodeType: 'start-agent', + label: getTopicLabel(block, name), + subtitle: 'Start Agent', + blockType: 'start_agent', + isStartAgent: true, + topicName: name, + diagnostics: blockDiagnostics, + }, + }); + + // Edge from Start to this start_agent + edges.push({ + id: `e-start-${nodeId}`, + source: 'start', + target: nodeId, + type: 'smoothstep', + }); + + addTransitionEdges(nodeId, block); + } + } + + // Topic nodes + const topics = ast.topic as NamedMap> | undefined; + if (isNamedMap(topics)) { + for (const [name, block] of topics) { + const nodeId = `topic-${name}`; + const blockDiagnostics = collectDiagnostics(block); + nodes.push({ + id: nodeId, + type: 'topic', + position: { x: 0, y: 0 }, + data: { + nodeType: 'topic', + label: getTopicLabel(block, name), + subtitle: 'Topic', + blockType: 'topic', + topicName: name, + diagnostics: blockDiagnostics, + }, + }); + + addTransitionEdges(nodeId, block); + } + } + + return { nodes, edges }; +} + +/** Resolve a topic name to its node ID (checking start_agent first, then topic). */ +function findTopicNodeId(name: string, ast: AgentScriptAST): string { + const startAgent = ast.start_agent as + | NamedMap> + | undefined; + if (isNamedMap(startAgent) && startAgent.has(name)) { + return `start_agent-${name}`; + } + return `topic-${name}`; +} + +// --------------------------------------------------------------------------- +// Topic Detail Graph — Execution Pipeline +// --------------------------------------------------------------------------- + +/** + * Convert a single topic into a detail graph showing its execution pipeline. + * + * Structure: + * [Topic Header] + * ↓ + * ┌─ BEFORE REASONING ─────────────┐ (container, only if has statements) + * │ [child run/set/template nodes] │ + * └────────────┬────────────────────┘ + * ↓ + * ┌─ REASONING LOOP ───────────────────────────────┐ + * │ [Before Reasoning Iteration] │ + * │ ↓ ↘ [template/conditional] │ + * │ [Agent Reasoning] │ + * │ ↓ │ + * │ [Tool Execution] │ + * │ ↘ [Action nodes] │ + * │ ↓ │ + * │ [After All Tool Calls] │ + * │ ↘ [Transition nodes] │ + * │ ↓ ↑ loop back │ + * └──────┬────────────┴─────────────────────────────┘ + * ↓ exit + * ┌─ AFTER REASONING ──────────────┐ (container, only if has statements) + * │ [child set/transition nodes] │ + * └────────────────────────────────┘ + * + * Nodes carry a `groupId` in their data so Graph.tsx can compute container + * bounding boxes after ELK layout and position the group background nodes. + */ +export function astToTopicDetailGraph( + ast: AgentScriptAST, + topicName: string +): { nodes: GraphNode[]; edges: GraphEdge[] } { + const nodes: GraphNode[] = []; + const edges: GraphEdge[] = []; + + const block = findTopicBlock(ast, topicName); + if (!block) return { nodes, edges }; + + const topicBlock = block as Record; + const topicDiagnostics = collectDiagnostics(topicBlock); + const reasoning = topicBlock.reasoning as Record | undefined; + + let condIdx = 0; + let transIdx = 0; + let runIdx = 0; + let setIdx = 0; + let tplIdx = 0; + const counters: IdCounters = { + getCondIdx: () => condIdx++, + getTransIdx: () => transIdx++, + getRunIdx: () => runIdx++, + getSetIdx: () => setIdx++, + getTplIdx: () => tplIdx++, + }; + + // Track the last pipeline node for sequential connections + let lastPipelineId: string | undefined; + let lastPipelineHandle: string | undefined; + let spineCounter = 0; + + const connectPipeline = (targetId: string) => { + // Tag the node as a spine node + const node = nodes.find(n => n.id === targetId); + if (node) { + node.data = { ...node.data, isSpine: true, spineIndex: spineCounter++ }; + } + + if (lastPipelineId) { + edges.push({ + id: `e-pipe-${lastPipelineId}-${targetId}`, + source: lastPipelineId, + sourceHandle: lastPipelineHandle, + target: targetId, + type: 'smoothstep', + data: { edgeRole: 'spine' }, + }); + } + lastPipelineId = targetId; + lastPipelineHandle = undefined; + }; + + // ----------------------------------------------------------------------- + // 0. Topic Header — always present + // ----------------------------------------------------------------------- + const headerId = 'topic-header'; + nodes.push({ + id: headerId, + type: 'phase', + position: { x: 0, y: 0 }, + data: { + nodeType: 'phase', + label: getTopicLabel(topicBlock, topicName), + subtitle: topicName, + blockType: 'topic', + phaseType: 'topic-header', + topicName, + diagnostics: topicDiagnostics, + }, + }); + connectPipeline(headerId); + + // ----------------------------------------------------------------------- + // 1. Before Reasoning phase (always shown; empty if no statements) + // ----------------------------------------------------------------------- + const beforeStatements = getProcedureStatements(topicBlock.before_reasoning); + const beforeEmpty = beforeStatements.length === 0; + + // Group container (visual — positioned post-layout) + nodes.push({ + id: GROUP_IDS.beforeReasoning, + type: 'reasoning-group', + position: { x: 0, y: 0 }, + data: { + nodeType: 'reasoning-group', + label: 'Before Reasoning', + blockType: 'topic', + isEmpty: beforeEmpty, + }, + }); + + // Phase header inside the container + const beforeId = 'before-reasoning'; + nodes.push({ + id: beforeId, + type: 'phase', + position: { x: 0, y: 0 }, + data: { + nodeType: 'phase', + label: 'Before Reasoning', + subtitle: beforeEmpty ? 'no hooks configured' : 'every turn', + blockType: 'topic', + phaseType: 'before_reasoning', + groupId: GROUP_IDS.beforeReasoning, + topicName, + isEmpty: beforeEmpty, + }, + }); + // Route spine through the before-reasoning group handles: + // previous → group (t-c) → group (enter-out) → before-reasoning → group (exit-in) → group (b-c) → next + edges.push({ + id: `e-pipe-${lastPipelineId}-${GROUP_IDS.beforeReasoning}`, + source: lastPipelineId!, + sourceHandle: lastPipelineHandle, + target: GROUP_IDS.beforeReasoning, + targetHandle: 'top', + type: 'smoothstep', + data: { edgeRole: 'spine' }, + }); + edges.push({ + id: `e-pipe-${GROUP_IDS.beforeReasoning}-${beforeId}`, + source: GROUP_IDS.beforeReasoning, + sourceHandle: 'enter-out', + target: beforeId, + type: 'smoothstep', + data: { edgeRole: 'spine' }, + }); + // Tag as spine manually (can't use connectPipeline — it would create a direct edge) + const beforeNode = nodes.find(n => n.id === beforeId); + if (beforeNode) { + beforeNode.data = { + ...beforeNode.data, + isSpine: true, + spineIndex: spineCounter++, + }; + } + lastPipelineId = beforeId; + lastPipelineHandle = undefined; + + if (!beforeEmpty) { + buildDetailNodes( + beforeStatements, + beforeId, + undefined, + nodes, + edges, + counters, + GROUP_IDS.beforeReasoning + ); + } + + // Exit the before-reasoning group + edges.push({ + id: `e-pipe-${lastPipelineId}-${GROUP_IDS.beforeReasoning}-exit`, + source: lastPipelineId!, + sourceHandle: lastPipelineHandle, + target: GROUP_IDS.beforeReasoning, + targetHandle: 'exit-in', + type: 'smoothstep', + data: { edgeRole: 'spine' }, + }); + lastPipelineId = GROUP_IDS.beforeReasoning; + lastPipelineHandle = 'bottom'; + + // ----------------------------------------------------------------------- + // 2. Reasoning Loop (always shown if reasoning block exists) + // ----------------------------------------------------------------------- + if (reasoning) { + // Group container (visual — positioned post-layout) + nodes.push({ + id: GROUP_IDS.reasoningLoop, + type: 'reasoning-group', + position: { x: 0, y: 0 }, + data: { + nodeType: 'reasoning-group', + label: 'Reasoning Loop', + blockType: 'topic', + }, + }); + + // 2a. Enter the loop: spine goes through group handles + // before-reasoning → group (top) → group (enter-out) → before-reasoning-iteration + edges.push({ + id: `e-pipe-${lastPipelineId}-${GROUP_IDS.reasoningLoop}`, + source: lastPipelineId!, + sourceHandle: lastPipelineHandle, + target: GROUP_IDS.reasoningLoop, + targetHandle: 'top', + type: 'smoothstep', + data: { edgeRole: 'spine' }, + }); + + const iterationId = 'before-reasoning-iteration'; + nodes.push({ + id: iterationId, + type: 'phase-label', + position: { x: 0, y: 0 }, + data: { + nodeType: 'phase-label', + label: 'Before Reasoning Iteration', + subtitle: 'every iteration', + blockType: 'topic', + phaseType: 'before_reasoning_iteration', + groupId: GROUP_IDS.reasoningLoop, + }, + }); + + // Edge from Enter Loop handle to first child inside the group + edges.push({ + id: `e-pipe-${GROUP_IDS.reasoningLoop}-${iterationId}`, + source: GROUP_IDS.reasoningLoop, + sourceHandle: 'enter-out', + target: iterationId, + type: 'smoothstep', + data: { edgeRole: 'spine' }, + }); + // Tag iteration as spine and continue pipeline from here + const iterNode = nodes.find(n => n.id === iterationId); + if (iterNode) { + iterNode.data = { + ...iterNode.data, + isSpine: true, + spineIndex: spineCounter++, + }; + } + lastPipelineId = iterationId; + lastPipelineHandle = undefined; + + // 2b. Build child nodes from reasoning.instructions (templates, conditionals) + const instrStatements = getProcedureStatements(reasoning.instructions); + const nodeCountBeforeInstr = nodes.length; + + const leafNodeIds = buildDetailNodes( + instrStatements, + iterationId, + undefined, + nodes, + edges, + counters, + GROUP_IDS.reasoningLoop + ); + + // Move transition nodes outside the loop (transitions are exits) + for (let i = nodeCountBeforeInstr; i < nodes.length; i++) { + if (nodes[i].data.nodeType === 'transition') { + nodes[i] = { + ...nodes[i], + data: { ...nodes[i].data, groupId: undefined }, + }; + } + } + + // 2c. Build Instructions node — collects template outputs + const buildInstrId = 'build-instructions'; + nodes.push({ + id: buildInstrId, + type: 'build-instructions', + position: { x: 0, y: 0 }, + data: { + nodeType: 'build-instructions', + label: 'Build Instructions', + blockType: 'topic', + groupId: GROUP_IDS.reasoningLoop, + }, + }); + // Tag as spine for layout positioning but no edge from iteration + const biNode = nodes.find(n => n.id === buildInstrId); + if (biNode) { + biNode.data = { + ...biNode.data, + isSpine: true, + spineIndex: spineCounter++, + }; + } + lastPipelineId = buildInstrId; + lastPipelineHandle = undefined; + + // Converge edges: each leaf instruction node → build-instructions (top handles) + for (const leafId of leafNodeIds) { + edges.push({ + id: `e-converge-${leafId}-${buildInstrId}`, + source: leafId, + sourceHandle: 'bottom', + target: buildInstrId, + type: 'smoothstep', + data: { edgeRole: 'converge' }, + }); + } + + // 2d. Agent Reasoning (LLM node) + const reasoningActions = reasoning.actions as + | NamedMap> + | undefined; + const actionDisplayNames: string[] = []; + const actionKeyNames: string[] = []; + if (isNamedMap(reasoningActions)) { + for (const [actionName, actionBlock] of reasoningActions) { + const actionLabel = + (actionBlock.label as { value?: string })?.value ?? + toDisplayLabel(actionName); + actionDisplayNames.push(actionLabel); + actionKeyNames.push(actionName); + } + } + + const llmId = 'reasoning-llm'; + nodes.push({ + id: llmId, + type: 'llm', + position: { x: 0, y: 0 }, + data: { + nodeType: 'llm', + label: 'Agent Reasoning', + subtitle: 'selects tools', + blockType: 'topic', + groupId: GROUP_IDS.reasoningLoop, + actionNames: actionDisplayNames, + actionKeys: actionKeyNames, + topicName, + }, + }); + connectPipeline(llmId); + + // Transition nodes from reasoning actions — OUTSIDE the loop (no groupId) + if (isNamedMap(reasoningActions)) { + for (const [, actionBlock] of reasoningActions) { + const actionStmts = actionBlock.statements as Statement[] | undefined; + if (actionStmts) { + buildDetailNodesFromReasoningAction( + actionStmts, + llmId, + nodes, + edges, + counters, + undefined // no groupId — transitions live outside the loop + ); + } + } + } + + // 2e. Loop-back edge from agent reasoning to before-reasoning-iteration + edges.push({ + id: 'e-loop-back', + source: llmId, + sourceHandle: 'left', + target: iterationId, + targetHandle: 'left', + type: 'loop-back', + }); + + // 2f. Exit the loop: spine goes through group handles + // reasoning-llm → group (exit-in) → group (b-c) → after-reasoning + edges.push({ + id: `e-pipe-${lastPipelineId}-${GROUP_IDS.reasoningLoop}-exit`, + source: lastPipelineId!, + sourceHandle: lastPipelineHandle, + target: GROUP_IDS.reasoningLoop, + targetHandle: 'exit-in', + type: 'smoothstep', + data: { edgeRole: 'spine' }, + }); + lastPipelineId = GROUP_IDS.reasoningLoop; + lastPipelineHandle = 'bottom'; + } + + // ----------------------------------------------------------------------- + // 3. After Reasoning phase (always shown; empty if no statements) + // ----------------------------------------------------------------------- + const afterStatements = getProcedureStatements(topicBlock.after_reasoning); + const afterEmpty = afterStatements.length === 0; + + // Group container (visual — positioned post-layout) + nodes.push({ + id: GROUP_IDS.afterReasoning, + type: 'reasoning-group', + position: { x: 0, y: 0 }, + data: { + nodeType: 'reasoning-group', + label: 'After Reasoning', + blockType: 'topic', + isEmpty: afterEmpty, + }, + }); + + // Phase header inside the container + const afterId = 'after-reasoning'; + nodes.push({ + id: afterId, + type: 'phase', + position: { x: 0, y: 0 }, + data: { + nodeType: 'phase', + label: 'After Reasoning', + subtitle: afterEmpty ? 'no hooks configured' : 'every turn', + blockType: 'topic', + phaseType: 'after_reasoning', + groupId: GROUP_IDS.afterReasoning, + }, + }); + // Route spine through the after-reasoning group handles: + // previous → group (top) → group (enter-out) → after-reasoning → group (exit-in) → group (bottom) → next + edges.push({ + id: `e-pipe-${lastPipelineId}-${GROUP_IDS.afterReasoning}`, + source: lastPipelineId!, + sourceHandle: lastPipelineHandle, + target: GROUP_IDS.afterReasoning, + targetHandle: 'top', + type: 'smoothstep', + data: { edgeRole: 'spine' }, + }); + edges.push({ + id: `e-pipe-${GROUP_IDS.afterReasoning}-${afterId}`, + source: GROUP_IDS.afterReasoning, + sourceHandle: 'enter-out', + target: afterId, + type: 'smoothstep', + data: { edgeRole: 'spine' }, + }); + // Tag as spine manually + const afterNode = nodes.find(n => n.id === afterId); + if (afterNode) { + afterNode.data = { + ...afterNode.data, + isSpine: true, + spineIndex: spineCounter++, + }; + } + lastPipelineId = afterId; + lastPipelineHandle = undefined; + + if (!afterEmpty) { + buildDetailNodes( + afterStatements, + afterId, + undefined, + nodes, + edges, + counters, + GROUP_IDS.afterReasoning + ); + } + + // Exit the after-reasoning group + edges.push({ + id: `e-pipe-${lastPipelineId}-${GROUP_IDS.afterReasoning}-exit`, + source: lastPipelineId!, + sourceHandle: lastPipelineHandle, + target: GROUP_IDS.afterReasoning, + targetHandle: 'exit-in', + type: 'smoothstep', + data: { edgeRole: 'spine' }, + }); + lastPipelineId = GROUP_IDS.afterReasoning; + lastPipelineHandle = 'bottom'; + + // ----------------------------------------------------------------------- + // Post-process: set React Flow parentId for group nesting + // ----------------------------------------------------------------------- + for (const node of nodes) { + if (node.data.groupId && node.data.nodeType !== 'reasoning-group') { + node.parentId = node.data.groupId as string; + } + } + + // React Flow requires parent nodes before their children in the array. + // Stable sort: group nodes first, then everything else in original order. + nodes.sort((a, b) => { + const aIsGroup = a.data.nodeType === 'reasoning-group' ? 0 : 1; + const bIsGroup = b.data.nodeType === 'reasoning-group' ? 0 : 1; + return aIsGroup - bIsGroup; + }); + + return { nodes, edges }; +} + +interface IdCounters { + getCondIdx: () => number; + getTransIdx: () => number; + getRunIdx: () => number; + getSetIdx: () => number; + getTplIdx: () => number; +} + +/** + * Recursively build detail graph nodes from a statement list. + * Chains sequential leaf nodes (Run, Set, Template) so they form a pipeline. + * Branching nodes (If, Transition) fan out from the current source + * without advancing the chain. + * + * Returns the IDs of "leaf" nodes — terminal endpoints of all paths + * through the instruction tree. Used for converge edges to Build Instructions. + * Transition nodes are excluded (they exit the loop). + */ +function buildDetailNodes( + statements: Statement[], + sourceId: string, + sourceHandle: string | undefined, + nodes: GraphNode[], + edges: GraphEdge[], + counters: IdCounters, + groupId?: string +): string[] { + // Track current source for sequential chaining of leaf nodes (Run, Set) + let curSource = sourceId; + let curHandle: string | undefined = sourceHandle; + const leafNodeIds: string[] = []; + + for (const stmt of statements) { + if (stmt.__kind === 'TransitionStatement') { + const transition = stmt as unknown as { clauses: Statement[] }; + for (const clause of transition.clauses) { + if (clause.__kind === 'ToClause') { + const toClause = clause as unknown as { target: unknown }; + const target = resolveTransitionTarget(toClause.target); + if (target) { + const transId = `transition-${counters.getTransIdx()}`; + nodes.push({ + id: transId, + type: 'transition', + position: { x: 0, y: 0 }, + data: { + nodeType: 'transition', + label: toDisplayLabel(target), + subtitle: 'Transition', + blockType: 'topic', + transitionTarget: target, + groupId, + }, + }); + edges.push({ + id: `e-${curSource}-${transId}`, + source: curSource, + sourceHandle: curHandle, + target: transId, + type: 'smoothstep', + data: { edgeRole: 'branch' }, + }); + } + } + } + // Transition is terminal — don't advance chain + } else if (stmt.__kind === 'IfStatement') { + const ifStmt = stmt as unknown as { + condition?: { __emit?(ctx: { indent: number }): string }; + body: Statement[]; + orelse: Statement[]; + }; + const condText = ifStmt.condition?.__emit?.({ indent: 0 }) ?? ''; + const condId = `conditional-${counters.getCondIdx()}`; + + nodes.push({ + id: condId, + type: 'conditional', + position: { x: 0, y: 0 }, + data: { + nodeType: 'conditional', + label: 'Conditional', + subtitle: 'Conditional', + blockType: 'conditional', + conditionText: condText, + conditionLabel: abbreviateCondition(condText), + groupId, + }, + }); + + edges.push({ + id: `e-${curSource}-${condId}`, + source: curSource, + sourceHandle: curHandle, + target: condId, + type: 'smoothstep', + data: { edgeRole: 'branch' }, + }); + + // If branch — collect leaf nodes + const ifLeaves = buildDetailNodes( + ifStmt.body, + condId, + 'if', + nodes, + edges, + counters, + groupId + ); + leafNodeIds.push(...ifLeaves); + + // Else branch — collect leaf nodes + if (ifStmt.orelse.length > 0) { + const elseLeaves = buildDetailNodes( + ifStmt.orelse, + condId, + 'else', + nodes, + edges, + counters, + groupId + ); + leafNodeIds.push(...elseLeaves); + } + // Branching — don't advance chain (subsequent statements fan from same source) + } else if (stmt.__kind === 'RunStatement') { + const runStmt = stmt as unknown as { target: unknown; body: Statement[] }; + const decomposed = decomposeAtMemberExpression(runStmt.target); + if (decomposed) { + const runId = `run-${counters.getRunIdx()}`; + nodes.push({ + id: runId, + type: 'run', + position: { x: 0, y: 0 }, + data: { + nodeType: 'run', + label: toDisplayLabel(decomposed.property), + subtitle: `@${decomposed.namespace}`, + blockType: 'actions', + groupId, + }, + }); + edges.push({ + id: `e-${curSource}-${runId}`, + source: curSource, + sourceHandle: curHandle, + target: runId, + type: 'smoothstep', + data: { edgeRole: 'branch' }, + }); + // Advance chain — next statement connects from this Run + curSource = runId; + curHandle = undefined; + + // Process nested body (runs can nest runs) + if (runStmt.body.length > 0) { + buildDetailNodes( + runStmt.body, + runId, + undefined, + nodes, + edges, + counters, + groupId + ); + } + } + } else if (stmt.__kind === 'SetClause') { + const setStmt = stmt as unknown as { + target: unknown; + value?: { __emit?(ctx: { indent: number }): string; value?: unknown }; + }; + const decomposed = decomposeAtMemberExpression(setStmt.target); + if (decomposed) { + const setId = `set-${counters.getSetIdx()}`; + const valueText = setStmt.value?.__emit + ? setStmt.value.__emit({ indent: 0 }) + : String(setStmt.value?.value ?? ''); + nodes.push({ + id: setId, + type: 'set', + position: { x: 0, y: 0 }, + data: { + nodeType: 'set', + label: `@${decomposed.namespace}.${decomposed.property}`, + subtitle: valueText, + blockType: 'set', + groupId, + }, + }); + edges.push({ + id: `e-${curSource}-${setId}`, + source: curSource, + sourceHandle: curHandle, + target: setId, + type: 'smoothstep', + data: { edgeRole: 'branch' }, + }); + // Advance chain — next statement connects from this Set + curSource = setId; + curHandle = undefined; + } + } else if (stmt.__kind === 'Template') { + const tpl = stmt as unknown as { + parts: Array<{ + __kind: string; + value?: string; + expression?: unknown; + }>; + }; + // Build a summary from template parts + const textParts: string[] = []; + for (const part of tpl.parts) { + if (part.__kind === 'TemplateText' && part.value) { + textParts.push(part.value.trim()); + } else if (part.__kind === 'TemplateInterpolation' && part.expression) { + const decomposed = decomposeAtMemberExpression(part.expression); + if (decomposed) { + textParts.push(`{@${decomposed.namespace}.${decomposed.property}}`); + } + } + } + const templateText = textParts.join(' ').trim(); + if (templateText) { + const tplId = `template-${counters.getTplIdx()}`; + nodes.push({ + id: tplId, + type: 'template', + position: { x: 0, y: 0 }, + data: { + nodeType: 'template', + label: templateText, + blockType: 'template', + groupId, + }, + }); + edges.push({ + id: `e-${curSource}-${tplId}`, + source: curSource, + sourceHandle: curHandle, + target: tplId, + type: 'smoothstep', + data: { edgeRole: 'branch' }, + }); + // Advance chain — templates append sequentially + curSource = tplId; + curHandle = undefined; + } + } + } + + // The final curSource (if it advanced beyond sourceId) is a leaf node. + // Transitions are excluded (they exit the loop, not converge). + if (curSource !== sourceId) { + const leafNode = nodes.find(n => n.id === curSource); + if ( + leafNode?.data.nodeType !== 'transition' && + leafNode?.data.nodeType !== 'set' + ) { + leafNodeIds.push(curSource); + } + } + + return leafNodeIds; +} + +/** + * Build detail graph nodes from a ReasoningActionBlock's statements. + * These contain ToClause (direct targets) and TransitionStatement wrappers. + */ +function buildDetailNodesFromReasoningAction( + statements: Statement[], + sourceId: string, + nodes: GraphNode[], + edges: GraphEdge[], + counters: IdCounters, + groupId?: string +): void { + for (const stmt of statements) { + if (stmt.__kind === 'ToClause') { + const toClause = stmt as unknown as { target: unknown }; + const target = resolveTransitionTarget(toClause.target); + if (target) { + const transId = `transition-${counters.getTransIdx()}`; + nodes.push({ + id: transId, + type: 'transition', + position: { x: 0, y: 0 }, + data: { + nodeType: 'transition', + label: toDisplayLabel(target), + subtitle: 'Transition', + blockType: 'topic', + transitionTarget: target, + groupId, + }, + }); + edges.push({ + id: `e-${sourceId}-${transId}`, + source: sourceId, + target: transId, + type: 'smoothstep', + data: { edgeRole: 'branch' }, + }); + } + } else if (stmt.__kind === 'TransitionStatement') { + const transition = stmt as unknown as { clauses: Statement[] }; + for (const clause of transition.clauses) { + if (clause.__kind === 'ToClause') { + const toClause = clause as unknown as { target: unknown }; + const target = resolveTransitionTarget(toClause.target); + if (target) { + const transId = `transition-${counters.getTransIdx()}`; + nodes.push({ + id: transId, + type: 'transition', + position: { x: 0, y: 0 }, + data: { + nodeType: 'transition', + label: toDisplayLabel(target), + subtitle: 'Transition', + blockType: 'topic', + transitionTarget: target, + groupId, + }, + }); + edges.push({ + id: `e-${sourceId}-${transId}`, + source: sourceId, + target: transId, + type: 'smoothstep', + data: { edgeRole: 'branch' }, + }); + } + } + } + } + } +} 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 00000000..aba88f75 --- /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/packages/graph-ui/src/ast/graph-layout.ts b/packages/graph-ui/src/ast/graph-layout.ts new file mode 100644 index 00000000..ab5f2244 --- /dev/null +++ b/packages/graph-ui/src/ast/graph-layout.ts @@ -0,0 +1,874 @@ +/* + * 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 + */ + +/** + * Graph Layout Engine + * + * Uses dagre for hierarchical layout of both overview and detail views. + * Nodes define multiple handles and edges are distributed across available + * handles to reduce visual overlap. + */ + +import dagre from '@dagrejs/dagre'; +import type { GraphNode, GraphEdge, GraphNodeType } from './ast-to-graph'; +import { + OVERVIEW_SIDES, + DETAIL_SIDES, + START_SIDES, + TERMINAL_SIDES, + PHASE_SIDES, + LLM_SIDES, + BUILD_INSTRUCTIONS_SIDES, + type HandleSide, +} from '../components/nodes/NodeHandles'; + +// --------------------------------------------------------------------------- +// Options +// --------------------------------------------------------------------------- + +export interface LayoutOptions { + direction: 'TB' | 'LR'; + /** When true, use detail handle side configs instead of overview. */ + isDetail?: boolean; + rankSep?: number; + nodeSep?: number; +} + +/** Default node dimensions per type. */ +const NODE_DIMENSIONS: Record< + GraphNodeType, + { width: number; height: number } +> = { + start: { width: 160, height: 56 }, + 'start-agent': { width: 260, height: 88 }, + topic: { width: 260, height: 88 }, + action: { width: 200, height: 70 }, + 'compound-topic': { width: 220, height: 200 }, + conditional: { width: 200, height: 56 }, + transition: { width: 200, height: 70 }, + run: { width: 200, height: 70 }, + set: { width: 240, height: 70 }, + template: { width: 300, height: 80 }, + phase: { width: 280, height: 56 }, + 'phase-label': { width: 280, height: 44 }, + llm: { width: 460, height: 72 }, + 'build-instructions': { width: 280, height: 44 }, + 'reasoning-group': { width: 10, height: 10 }, +}; + +function getNodeDimensions(node: GraphNode): { + width: number; + height: number; +} { + const base = NODE_DIMENSIONS[node.data.nodeType] ?? { + width: 200, + height: 70, + }; + + // Dynamic height for LLM nodes with action pills + if (node.data.nodeType === 'llm') { + const actions = node.data.actionNames as string[] | undefined; + if (actions && actions.length > 0) { + const pillRows = Math.ceil(actions.length / 4); + // border-t (1px) + py-3 (24px) + rows * (pill height ~26px + gap 8px) + const pillSectionHeight = 25 + pillRows * 28; + return { width: base.width, height: base.height + pillSectionHeight }; + } + } + + return base; +} + +// --------------------------------------------------------------------------- +// Handle side config per node type +// --------------------------------------------------------------------------- + +interface SideConfig { + type: 'source' | 'target'; +} + +function getOverviewSides( + nodeType: GraphNodeType +): Partial> { + switch (nodeType) { + case 'start': + return START_SIDES; + case 'transition': + return TERMINAL_SIDES; + default: + return OVERVIEW_SIDES; + } +} + +function getDetailSides( + nodeType: GraphNodeType +): Partial> { + switch (nodeType) { + case 'transition': + return TERMINAL_SIDES; + case 'phase': + case 'phase-label': + return PHASE_SIDES; + case 'llm': + return LLM_SIDES; + case 'build-instructions': + return BUILD_INSTRUCTIONS_SIDES; + case 'conditional': + return { top: { type: 'target' }, bottom: { type: 'source' } }; + case 'reasoning-group': + // Handles are rendered directly in the component (not via NodeHandles grid) + return {}; + default: + return DETAIL_SIDES; + } +} + +// --------------------------------------------------------------------------- +// Route point type (used by edge components for orthogonal routing) +// --------------------------------------------------------------------------- + +export interface RoutePoint { + x: number; + y: number; +} + +// --------------------------------------------------------------------------- +// Layout +// --------------------------------------------------------------------------- + +export interface LayoutResult { + nodes: GraphNode[]; + edges: GraphEdge[]; + /** Map of nodeId → Set of connected handle IDs (local, not prefixed) */ + connectedHandles: Map>; +} + +/** + * Dagre-based layout for the overview graph. + * + * Positions nodes in a hierarchical layered layout (TB) and distributes + * edges across available handles to reduce overlap. + * + * Synchronous — instant render. + */ +export function applyDagreOverviewLayout( + nodes: GraphNode[], + edges: GraphEdge[], + options: LayoutOptions +): LayoutResult { + if (nodes.length === 0) { + return { nodes: [], edges: [], connectedHandles: new Map() }; + } + + const g = new dagre.graphlib.Graph({ directed: true }); + g.setGraph({ + rankdir: options.direction, + nodesep: options.nodeSep ?? 80, + ranksep: options.rankSep ?? 120, + marginx: 40, + marginy: 40, + ranker: 'network-simplex', + }); + g.setDefaultNodeLabel(() => ({})); + g.setDefaultEdgeLabel(() => ({})); + + for (const node of nodes) { + const dims = getNodeDimensions(node); + g.setNode(node.id, { width: dims.width, height: dims.height }); + } + + for (const edge of edges) { + if (!g.hasNode(edge.source) || !g.hasNode(edge.target)) continue; + g.setEdge(edge.source, edge.target); + } + + dagre.layout(g); + + // Extract positions (dagre returns center coords → convert to top-left) + const positionedNodes = nodes.map(node => { + const dn = g.node(node.id) as + | { x: number; y: number; width: number; height: number } + | undefined; + return { + ...node, + position: { + x: dn ? dn.x - dn.width / 2 : 0, + y: dn ? dn.y - dn.height / 2 : 0, + }, + }; + }); + + // Assign handles to edges using handle pools (same approach as detail layout) + const sourcePool = new Map(); + const targetPool = new Map(); + const consumedSet = new Map>(); + + for (const node of nodes) { + const sides = getOverviewSides(node.data.nodeType); + + const sources: string[] = []; + if (sides.bottom?.type === 'source') sources.push('bottom'); + if (sides.right?.type === 'source') sources.push('right'); + + const targets: string[] = []; + if (sides.top?.type === 'target') targets.push('top'); + if (sides.left?.type === 'target') targets.push('left'); + + sourcePool.set(node.id, sources); + targetPool.set(node.id, targets); + consumedSet.set(node.id, new Set()); + } + + function consume(nodeId: string, handleId: string): void { + consumedSet.get(nodeId)?.add(handleId); + } + + function takeSource(nodeId: string): string { + const pool = sourcePool.get(nodeId) ?? []; + const used = consumedSet.get(nodeId) ?? new Set(); + for (const h of pool) { + if (!used.has(h)) { + consume(nodeId, h); + return h; + } + } + return 'bottom'; + } + + function takeTarget(nodeId: string): string { + const pool = targetPool.get(nodeId) ?? []; + const used = consumedSet.get(nodeId) ?? new Set(); + for (const h of pool) { + if (!used.has(h)) { + consume(nodeId, h); + return h; + } + } + return 'top'; + } + + // Reserve explicit handles first + for (const edge of edges) { + if (edge.sourceHandle) consume(edge.source, edge.sourceHandle); + if (edge.targetHandle) consume(edge.target, edge.targetHandle); + } + + const connectedHandles = new Map>(); + + const updatedEdges = edges.map(edge => { + const sourceHandle = edge.sourceHandle ?? takeSource(edge.source); + const targetHandle = edge.targetHandle ?? takeTarget(edge.target); + + trackHandle(connectedHandles, edge.source, sourceHandle); + trackHandle(connectedHandles, edge.target, targetHandle); + + return { + ...edge, + sourceHandle, + targetHandle, + }; + }); + + return { + nodes: positionedNodes, + edges: updatedEdges, + connectedHandles, + }; +} + +// --------------------------------------------------------------------------- +// Dagre Detail Layout (with React Flow parentId nesting) +// --------------------------------------------------------------------------- + +/** Padding inside group containers. */ +const GROUP_PAD = { x: 72, top: 64, bottom: 60 }; +const GROUP_PAD_EMPTY = { x: 36, top: 48, bottom: 36 }; + +/** Uniform width for spine nodes in detail view. */ +const SPINE_WIDTH = 460; + +/** + * Dagre-based layout for the topic detail view. + * + * Runs dagre on all non-group nodes, computes group bounding boxes from + * member positions, then converts child positions to parent-relative + * coordinates for React Flow's parentId nesting. + * + * Synchronous — instant render. + */ +export function applyDagreDetailLayout( + nodes: GraphNode[], + edges: GraphEdge[] +): LayoutResult { + if (nodes.length === 0) { + return { nodes: [], edges: [], connectedHandles: new Map() }; + } + + // ------------------------------------------------------------------------- + // 1. Build flat dagre graph (excluding group container nodes) + // ------------------------------------------------------------------------- + const g = new dagre.graphlib.Graph({ directed: true }); + g.setGraph({ + rankdir: 'TB', + nodesep: 80, + ranksep: 100, + marginx: 40, + marginy: 40, + ranker: 'network-simplex', + }); + g.setDefaultNodeLabel(() => ({})); + g.setDefaultEdgeLabel(() => ({})); + + const nodeMap = new Map(); + for (const node of nodes) { + nodeMap.set(node.id, node); + if (node.data.nodeType === 'reasoning-group') continue; + + const dims = getNodeDimensions(node); + g.setNode(node.id, { + width: node.data.isSpine ? SPINE_WIDTH : dims.width, + height: dims.height, + }); + } + + // Add edges with weights based on role: + // spine edges (high weight) keep the main flow tight and vertical + // branch/converge edges (low weight) allow horizontal spread + // cross-group spine edges get minlen=2 to prevent group overlap + for (const edge of edges) { + if (edge.type === 'loop-back') continue; + if (!g.hasNode(edge.source) || !g.hasNode(edge.target)) continue; + + const edgeData = edge.data as Record | undefined; + const edgeRole = edgeData?.edgeRole as string | undefined; + + // Detect cross-group boundary (different groupId on source vs target) + const sourceGroup = nodeMap.get(edge.source)?.data.groupId as + | string + | undefined; + const targetGroup = nodeMap.get(edge.target)?.data.groupId as + | string + | undefined; + const isCrossGroup = sourceGroup !== targetGroup; + + let weight = 1; + let minlen = 1; + if (edgeRole === 'spine') { + weight = 10; + // Extra rank separation at group boundaries for visual breathing room + minlen = isCrossGroup ? 3 : 1; + } else if (edgeRole === 'converge') { + weight = 1; + minlen = 1; + } else { + // branch edges + weight = 1; + minlen = 1; + } + + g.setEdge(edge.source, edge.target, { weight, minlen }); + } + + // Add bridge edges for edges that pass through group nodes. + // Group nodes aren't in dagre, so we create direct edges between the + // non-group endpoints to preserve layout ordering. + // Enter pair: A → group (t-c), group (enter-out) → B ⇒ bridge A → B + // Exit pair: A → group (exit-in), group (b-c) → B ⇒ bridge A → B + const groupIncoming = new Map< + string, + Array<{ source: string; targetHandle?: string | null }> + >(); + const groupOutgoing = new Map< + string, + Array<{ target: string; sourceHandle?: string | null }> + >(); + for (const edge of edges) { + if (edge.type === 'loop-back') continue; + const targetNode = nodeMap.get(edge.target); + const sourceNode = nodeMap.get(edge.source); + if (targetNode?.data.nodeType === 'reasoning-group') { + const arr = groupIncoming.get(edge.target) ?? []; + arr.push({ source: edge.source, targetHandle: edge.targetHandle }); + groupIncoming.set(edge.target, arr); + } + if (sourceNode?.data.nodeType === 'reasoning-group') { + const arr = groupOutgoing.get(edge.source) ?? []; + arr.push({ target: edge.target, sourceHandle: edge.sourceHandle }); + groupOutgoing.set(edge.source, arr); + } + } + for (const [groupId, inEdges] of groupIncoming) { + const outEdges = groupOutgoing.get(groupId); + if (!outEdges) continue; + for (const inc of inEdges) { + for (const out of outEdges) { + // Match enter pair (top / enter-out) or exit pair (exit-in / bottom) + const isEnter = + inc.targetHandle === 'top' && out.sourceHandle === 'enter-out'; + const isExit = + inc.targetHandle === 'exit-in' && out.sourceHandle === 'bottom'; + if ( + (isEnter || isExit) && + g.hasNode(inc.source) && + g.hasNode(out.target) + ) { + g.setEdge(inc.source, out.target, { weight: 10, minlen: 3 }); + } + } + } + } + + // Handle group-to-group spine transitions. + // When the spine passes through consecutive groups (e.g., before-reasoning → reasoning-loop), + // both endpoints are group nodes (not in dagre), so the standard bridge logic above fails. + // Fix: find the last real node exiting the source group and the first real node entering + // the target group, then create a bridge edge to preserve vertical ordering. + for (const edge of edges) { + if (edge.type === 'loop-back') continue; + const srcNode = nodeMap.get(edge.source); + const tgtNode = nodeMap.get(edge.target); + if ( + srcNode?.data.nodeType !== 'reasoning-group' || + tgtNode?.data.nodeType !== 'reasoning-group' + ) + continue; + + // Find the node flowing into the source group's exit-in handle + let exitNodeId: string | undefined; + for (const e of edges) { + if (e.target === edge.source && e.targetHandle === 'exit-in') { + exitNodeId = e.source; + break; + } + } + + // Find the node flowing out of the target group's enter-out handle + let enterNodeId: string | undefined; + for (const e of edges) { + if (e.source === edge.target && e.sourceHandle === 'enter-out') { + enterNodeId = e.target; + break; + } + } + + if ( + exitNodeId && + enterNodeId && + g.hasNode(exitNodeId) && + g.hasNode(enterNodeId) + ) { + g.setEdge(exitNodeId, enterNodeId, { weight: 10, minlen: 3 }); + } + } + + // ------------------------------------------------------------------------- + // 2. Run dagre layout + // ------------------------------------------------------------------------- + dagre.layout(g); + + // ------------------------------------------------------------------------- + // 3. Extract absolute positions (dagre returns center coords → top-left) + // ------------------------------------------------------------------------- + interface Rect { + x: number; + y: number; + w: number; + h: number; + } + const absPositions = new Map(); + + for (const nodeId of g.nodes()) { + const dn = g.node(nodeId) as + | { x: number; y: number; width: number; height: number } + | undefined; + if (!dn) continue; + absPositions.set(nodeId, { + x: dn.x - dn.width / 2, + y: dn.y - dn.height / 2, + w: dn.width, + h: dn.height, + }); + } + + // ------------------------------------------------------------------------- + // 4. Compute group bounding boxes from member absolute positions + // ------------------------------------------------------------------------- + const groupNodes = nodes.filter(n => n.data.nodeType === 'reasoning-group'); + const groupPositions = new Map(); + + for (const groupNode of groupNodes) { + const members: Rect[] = []; + for (const [id, pos] of absPositions) { + const n = nodeMap.get(id); + if (n?.data.groupId === groupNode.id) members.push(pos); + } + if (members.length === 0) continue; + + let minX = Infinity, + minY = Infinity, + maxX = -Infinity, + maxY = -Infinity; + for (const m of members) { + minX = Math.min(minX, m.x); + minY = Math.min(minY, m.y); + maxX = Math.max(maxX, m.x + m.w); + maxY = Math.max(maxY, m.y + m.h); + } + + const isEmpty = groupNode.data.isEmpty === true; + const pad = isEmpty ? GROUP_PAD_EMPTY : GROUP_PAD; + + groupPositions.set(groupNode.id, { + x: minX - pad.x, + y: minY - pad.top, + w: maxX - minX + pad.x * 2, + h: maxY - minY + pad.top + pad.bottom, + }); + } + + // ------------------------------------------------------------------------- + // 4b. Center all group containers on the spine + // Find the spine center X (from spine nodes), then horizontally center + // each group around it — widening narrower groups so they all share + // a common center line. + // ------------------------------------------------------------------------- + let spineCenterX: number | undefined; + for (const [id, pos] of absPositions) { + const n = nodeMap.get(id); + if (n?.data.isSpine) { + spineCenterX = pos.x + pos.w / 2; + break; + } + } + + if (spineCenterX !== undefined) { + // Find the max half-width across all groups so they all share + // the same width — aligned and centered on the spine. + let maxHalfWidth = 0; + for (const [, pos] of groupPositions) { + const leftDist = spineCenterX - pos.x; + const rightDist = pos.x + pos.w - spineCenterX; + maxHalfWidth = Math.max(maxHalfWidth, leftDist, rightDist); + } + + for (const [groupId, pos] of groupPositions) { + groupPositions.set(groupId, { + ...pos, + x: spineCenterX - maxHalfWidth, + w: maxHalfWidth * 2, + }); + } + } + + // ------------------------------------------------------------------------- + // 4c. Stretch LLM nodes to fill their group container width + // ------------------------------------------------------------------------- + for (const node of nodes) { + if (node.data.nodeType === 'llm' && node.data.groupId) { + const groupPos = groupPositions.get(node.data.groupId as string); + const abs = absPositions.get(node.id); + if (groupPos && abs) { + const pad = GROUP_PAD.x; + absPositions.set(node.id, { + x: groupPos.x + pad, + y: abs.y, + w: groupPos.w - pad * 2, + h: abs.h, + }); + } + } + } + + // ------------------------------------------------------------------------- + // 5. Build positioned node array + // - Group nodes first (React Flow requires parents before children) + // - Child positions converted to parent-relative coordinates + // ------------------------------------------------------------------------- + const positioned: GraphNode[] = []; + + // Group containers + for (const groupNode of groupNodes) { + const pos = groupPositions.get(groupNode.id); + if (!pos) continue; + positioned.push({ + ...groupNode, + position: { x: pos.x, y: pos.y }, + style: { width: pos.w, height: pos.h, zIndex: -1 }, + selectable: false, + draggable: false, + }); + } + + // All other nodes + for (const node of nodes) { + if (node.data.nodeType === 'reasoning-group') continue; + const absPos = absPositions.get(node.id); + if (!absPos) continue; + + let position = { x: absPos.x, y: absPos.y }; + + // Convert to parent-relative if this node has a parentId (group child) + if (node.parentId) { + const parentPos = groupPositions.get(node.parentId); + if (parentPos) { + position = { + x: absPos.x - parentPos.x, + y: absPos.y - parentPos.y, + }; + } + } + + positioned.push({ + ...node, + position, + ...(node.data.nodeType === 'llm' && node.parentId + ? { style: { width: absPos.w } } + : node.data.isSpine + ? { width: SPINE_WIDTH } + : {}), + }); + } + + // ------------------------------------------------------------------------- + // 6. Assign handles to edges + compute route points + // - Each handle used at most once per node + // - TB layout: prefer bottom handles for sources, top for targets + // ------------------------------------------------------------------------- + + // Build source/target handle pools per node based on side configs. + // Order: bottom/top preferred (TB primary), then side handles as overflow. + const sourcePool = new Map(); + const targetPool = new Map(); + const consumedSet = new Map>(); + + for (const node of nodes) { + if (node.data.nodeType === 'reasoning-group') continue; + const sides = getDetailSides(node.data.nodeType); + + const sources: string[] = []; + if (sides.bottom?.type === 'source') sources.push('bottom'); + if (sides.right?.type === 'source') sources.push('right'); + if (sides.left?.type === 'source') sources.push('left'); + + const targets: string[] = []; + if (sides.top?.type === 'target') targets.push('top'); + if (sides.left?.type === 'target') targets.push('left'); + if (sides.right?.type === 'target') targets.push('right'); + + sourcePool.set(node.id, sources); + targetPool.set(node.id, targets); + consumedSet.set(node.id, new Set()); + } + + function consume(nodeId: string, handleId: string): void { + consumedSet.get(nodeId)?.add(handleId); + } + + function takeSource(nodeId: string): string { + const pool = sourcePool.get(nodeId) ?? []; + const used = consumedSet.get(nodeId) ?? new Set(); + for (const h of pool) { + if (!used.has(h)) { + consume(nodeId, h); + return h; + } + } + return 'bottom'; + } + + function takeTarget(nodeId: string): string { + const pool = targetPool.get(nodeId) ?? []; + const used = consumedSet.get(nodeId) ?? new Set(); + for (const h of pool) { + if (!used.has(h)) { + consume(nodeId, h); + return h; + } + } + return 'top'; + } + + // Helper: check if a node is a group container (no handle pools) + const isGroupNode = (nodeId: string): boolean => + nodeMap.get(nodeId)?.data.nodeType === 'reasoning-group'; + + // Reserve handles that are already spoken for: + // - spine edges always use bottom → top (except for group-connected edges) + // - loop-back edges have pre-assigned handles + // - any edge with explicit sourceHandle/targetHandle from ast-to-graph + for (const edge of edges) { + if (edge.type === 'loop-back') { + if (edge.sourceHandle) consume(edge.source, edge.sourceHandle); + if (edge.targetHandle) consume(edge.target, edge.targetHandle); + continue; + } + const ed = edge.data as Record | undefined; + if ((ed?.edgeRole as string | undefined) === 'spine') { + // Group nodes have custom handles — don't reserve standard spine handles + if (!isGroupNode(edge.source)) consume(edge.source, 'bottom'); + if (!isGroupNode(edge.target)) consume(edge.target, 'top'); + } + if (edge.sourceHandle && !isGroupNode(edge.source)) + consume(edge.source, edge.sourceHandle); + if (edge.targetHandle && !isGroupNode(edge.target)) + consume(edge.target, edge.targetHandle); + } + + // Absolute handle position from node rect + handle ID + function handleAbsPos(rect: Rect, handleId: string): RoutePoint { + const { x, y, w, h: ht } = rect; + switch (handleId) { + case 'top': + return { x: x + w * 0.5, y }; + case 'bottom': + return { x: x + w * 0.5, y: y + ht }; + case 'left': + return { x, y: y + ht * 0.5 }; + case 'right': + return { x: x + w, y: y + ht * 0.5 }; + // Conditional node if/else handles at 30% and 70% along the bottom + case 'if': + return { x: x + w * 0.3, y: y + ht }; + case 'else': + return { x: x + w * 0.7, y: y + ht }; + // Custom handles for reasoning loop group enter/exit (at the border) + case 'enter-out': + return { x: x + w * 0.5, y }; + case 'exit-in': + return { x: x + w * 0.5, y: y + ht }; + default: + return { x: x + w * 0.5, y: y + ht }; + } + } + + const connectedHandles = new Map>(); + + const lookupAbs = (nodeId: string): Rect | undefined => + absPositions.get(nodeId) ?? groupPositions.get(nodeId); + + const updatedEdges = edges.map(edge => { + // Loop-back: keep pre-assigned handles, inject group container left X for edge snapping + if (edge.type === 'loop-back') { + trackHandle(connectedHandles, edge.source, edge.sourceHandle); + trackHandle(connectedHandles, edge.target, edge.targetHandle); + const sourceNode = nodeMap.get(edge.source); + const groupId = sourceNode?.data.groupId as string | undefined; + const groupPos = groupId ? groupPositions.get(groupId) : undefined; + return { + ...edge, + data: { + ...(edge.data as Record | undefined), + groupLeftX: groupPos?.x, + }, + }; + } + + const edgeData = edge.data as Record | undefined; + const edgeRole = edgeData?.edgeRole as string | undefined; + const isSpineEdge = edgeRole === 'spine'; + + let sourceHandle: string; + let targetHandle: string; + + if (isSpineEdge) { + // Group-connected spine edges use their explicit handles (enter-out, exit-in, etc.) + // rather than the standard bottom → top spine handles. + sourceHandle = isGroupNode(edge.source) + ? (edge.sourceHandle ?? 'bottom') + : 'bottom'; + targetHandle = isGroupNode(edge.target) + ? (edge.targetHandle ?? 'top') + : 'top'; + } else { + sourceHandle = edge.sourceHandle ?? takeSource(edge.source); + targetHandle = edge.targetHandle ?? takeTarget(edge.target); + } + + trackHandle(connectedHandles, edge.source, sourceHandle); + trackHandle(connectedHandles, edge.target, targetHandle); + + // Compute route points (absolute coords for React Flow edge rendering) + let elkPoints: RoutePoint[] | undefined; + + if (!isSpineEdge) { + const sourcePos = lookupAbs(edge.source); + const targetPos = lookupAbs(edge.target); + if (sourcePos && targetPos) { + const src = handleAbsPos(sourcePos, sourceHandle); + const tgt = handleAbsPos(targetPos, targetHandle); + const srcDown = + sourceHandle === 'bottom' || + sourceHandle === 'if' || + sourceHandle === 'else'; + const srcRight = sourceHandle === 'right'; + const tgtUp = targetHandle === 'top'; + const tgtLeft = targetHandle === 'left'; + + if (Math.abs(src.x - tgt.x) < 5) { + // Nearly aligned vertically — straight line + elkPoints = [src, tgt]; + } else if (srcDown && tgtUp) { + // Both vertical (bottom → top): step route via midpoint Y + const midY = (src.y + tgt.y) / 2; + elkPoints = [src, { x: src.x, y: midY }, { x: tgt.x, y: midY }, tgt]; + } else if (srcDown && tgtLeft) { + // Down from source, then across to left-side target + elkPoints = [src, { x: src.x, y: tgt.y }, tgt]; + } else if (srcRight && tgtUp) { + // Right from source, then up to top target + elkPoints = [src, { x: tgt.x, y: src.y }, tgt]; + } else if (srcRight && tgtLeft) { + // Horizontal: straight or S-bend + if (Math.abs(src.y - tgt.y) < 5) { + elkPoints = [src, tgt]; + } else { + const midX = (src.x + tgt.x) / 2; + elkPoints = [ + src, + { x: midX, y: src.y }, + { x: midX, y: tgt.y }, + tgt, + ]; + } + } else { + // Default: L-bend + elkPoints = [src, { x: tgt.x, y: src.y }, tgt]; + } + } + } + + return { + ...edge, + sourceHandle, + targetHandle, + data: { + ...(edgeData ?? {}), + ...(elkPoints ? { elkPoints } : {}), + }, + }; + }); + + return { + nodes: positioned, + edges: updatedEdges, + connectedHandles, + }; +} + +function trackHandle( + map: Map>, + nodeId: string, + handle: string | null | undefined +): void { + if (!handle) return; + let set = map.get(nodeId); + if (!set) { + set = new Set(); + map.set(nodeId, set); + } + set.add(handle); +} diff --git a/packages/graph-ui/src/ast/graph-path.ts b/packages/graph-ui/src/ast/graph-path.ts new file mode 100644 index 00000000..fce77c9b --- /dev/null +++ b/packages/graph-ui/src/ast/graph-path.ts @@ -0,0 +1,87 @@ +/* + * 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 + */ + +/** + * Graph Path Finding + * + * Finds all edges on simple (non-repeating) routes from start to target. + * Used for path highlighting when a node is selected. + */ + +import type { GraphEdge } from './ast-to-graph'; + +/** + * Find edges to highlight when selecting `targetId`, showing all simple + * routes from `startId` to `targetId`. + * + * Algorithm: + * 1. Backward BFS from target to find which nodes can reach the target. + * This prunes the search space so the DFS doesn't explore dead ends. + * 2. Forward DFS from start, only exploring nodes that can reach the target. + * The visited set prevents revisiting nodes, naturally handling cycles. + * 3. Every simple path found contributes its edges to the result set. + */ +export function findPathEdges( + edges: GraphEdge[], + startId: string, + targetId: string +): Set | null { + if (startId === targetId) return new Set(); + + // Build forward and reverse adjacency + const fwd = new Map>(); + const rev = new Map(); + for (const edge of edges) { + if (!fwd.has(edge.source)) fwd.set(edge.source, []); + fwd.get(edge.source)!.push({ target: edge.target, edgeId: edge.id }); + if (!rev.has(edge.target)) rev.set(edge.target, []); + rev.get(edge.target)!.push(edge.source); + } + + // Backward BFS: find all nodes that can reach the target + const canReachTarget = new Set([targetId]); + const queue = [targetId]; + while (queue.length > 0) { + const node = queue.shift()!; + for (const src of rev.get(node) ?? []) { + if (!canReachTarget.has(src)) { + canReachTarget.add(src); + queue.push(src); + } + } + } + + if (!canReachTarget.has(startId)) return null; + + // Forward DFS: enumerate all simple paths, collecting edges + const result = new Set(); + const pathEdges: string[] = []; + const visited = new Set([startId]); + + function dfs(current: string): boolean { + if (current === targetId) { + for (const edgeId of pathEdges) { + result.add(edgeId); + } + return true; + } + + let found = false; + for (const { target, edgeId } of fwd.get(current) ?? []) { + if (visited.has(target) || !canReachTarget.has(target)) continue; + visited.add(target); + pathEdges.push(edgeId); + if (dfs(target)) found = true; + pathEdges.pop(); + visited.delete(target); + } + return found; + } + + dfs(startId); + return result.size > 0 ? result : null; +} diff --git a/packages/graph-ui/src/components/edges/AnimatedEdge.tsx b/packages/graph-ui/src/components/edges/AnimatedEdge.tsx new file mode 100644 index 00000000..3aa0c7a9 --- /dev/null +++ b/packages/graph-ui/src/components/edges/AnimatedEdge.tsx @@ -0,0 +1,139 @@ +/* + * 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 { BaseEdge, getSmoothStepPath, type EdgeProps } from '@xyflow/react'; +import { useGraphContext } from '../../context/GraphContext'; + +const CHEVRON_COUNT = 2; +const DURATION = 2.4; +const HIGHLIGHT_COLOR = '#3b82f6'; +const SPINE_COLOR = '#6366f1'; // indigo-500 +const SECONDARY_COLOR = '#64748b'; // slate-500 — visible on #141414 canvas + +/** + * Default edge with animated chevrons flowing source→target. + * Uses ELK-computed routes when available for proper edge spacing. + * Reads highlight state from the Zustand store (bypasses React Flow memoization). + */ +export function AnimatedEdge({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + style, + markerEnd, + data, +}: EdgeProps) { + // Always use getSmoothStepPath for guaranteed right-angle (orthogonal) routing + const [edgePath] = getSmoothStepPath({ + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + }); + + // Determine edge role for visual hierarchy + const edgeData = data as Record | undefined; + const edgeRole = edgeData?.edgeRole as string | undefined; + const isSpine = edgeRole === 'spine'; + + const { highlightedEdgeIds } = useGraphContext(); + const isHighlighted = highlightedEdgeIds?.has(id) ?? false; + const isDimmed = highlightedEdgeIds != null && !isHighlighted; + + // Visual hierarchy: two tiers — primary (spine) and secondary (everything else) + let strokeColor: string; + let strokeWidth: number; + let chevronColor: string; + let chevronOpacity: number; + let chevronSize: string; + let glowFilter: string | undefined; + + if (isHighlighted) { + strokeColor = HIGHLIGHT_COLOR; + strokeWidth = 3; + chevronColor = HIGHLIGHT_COLOR; + chevronOpacity = 0.9; + chevronSize = '0,-5 8,0 0,5'; + glowFilter = 'url(#edge-glow)'; + } else if (isSpine) { + strokeColor = SPINE_COLOR; + strokeWidth = 2; + chevronColor = SPINE_COLOR; + chevronOpacity = 0.7; + chevronSize = '0,-4 7,0 0,4'; + } else { + strokeColor = (style?.stroke as string) ?? SECONDARY_COLOR; + strokeWidth = 2; + chevronColor = SECONDARY_COLOR; + chevronOpacity = 0.6; + chevronSize = '0,-4 7,0 0,4'; + } + + const groupOpacity = isDimmed ? 0.1 : 1; + + return ( + + {/* Glow filter definition for highlighted edges */} + + + + + + + + + + + {Array.from({ length: CHEVRON_COUNT }, (_, i) => { + const begin = `${(i * DURATION) / CHEVRON_COUNT}s`; + return ( + + + + + ); + })} + + ); +} diff --git a/packages/graph-ui/src/components/edges/ConditionalEdge.tsx b/packages/graph-ui/src/components/edges/ConditionalEdge.tsx new file mode 100644 index 00000000..c45f9d61 --- /dev/null +++ b/packages/graph-ui/src/components/edges/ConditionalEdge.tsx @@ -0,0 +1,150 @@ +/* + * 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 { + BaseEdge, + EdgeLabelRenderer, + getSmoothStepPath, + type EdgeProps, +} from '@xyflow/react'; +import { ShieldCheck } from 'lucide-react'; +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'; +const CHEVRON_COUNT = 2; +const DURATION = 2.4; + +/** + * Conditional edge with flowing chevrons and a clickable gate icon. + * Uses ELK-computed routes when available for proper edge spacing. + * Hover over the gate icon to see condition text; click to open the drawer. + * Reads highlight state from the Zustand store (bypasses React Flow memoization). + */ +export function ConditionalEdge({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + label, + style, + markerEnd, + data, +}: EdgeProps) { + // Always use getSmoothStepPath for guaranteed right-angle (orthogonal) routing + const [edgePath, labelX, labelY] = getSmoothStepPath({ + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + }); + + const { highlightedEdgeIds, onConditionalClick } = useGraphContext(); + const isHighlighted = highlightedEdgeIds?.has(id) ?? false; + const isDimmed = highlightedEdgeIds != null && !isHighlighted; + + const strokeColor = isHighlighted + ? HIGHLIGHT_COLOR + : ((style?.stroke as string) ?? COLOR); + const strokeWidth = isHighlighted ? 3 : 1.5; + const chevronColor = isHighlighted ? HIGHLIGHT_COLOR : COLOR; + const groupOpacity = isDimmed ? 0.1 : 1; + + const edgeData = data as + | (ConditionalEdgeData & Record) + | undefined; + + const handleGateClick = () => { + if (edgeData?.conditionText && edgeData?.sourceTopicName) { + onConditionalClick?.({ + edgeId: id, + conditionText: edgeData.conditionText, + sourceTopicName: edgeData.sourceTopicName, + conditionalKey: edgeData.conditionalKey ?? edgeData.conditionText, + }); + } + }; + + return ( + + + + {/* Flowing chevrons */} + {Array.from({ length: CHEVRON_COUNT }, (_, i) => { + const begin = `${(i * DURATION) / CHEVRON_COUNT}s`; + return ( + + + + + ); + })} + + {/* Gate icon with hover tooltip + click to open drawer */} + {label && ( + +
+
+ +
+
+
+ {label} +
+
+
+
+ )} +
+ ); +} diff --git a/packages/graph-ui/src/components/edges/LoopBackEdge.tsx b/packages/graph-ui/src/components/edges/LoopBackEdge.tsx new file mode 100644 index 00000000..83422370 --- /dev/null +++ b/packages/graph-ui/src/components/edges/LoopBackEdge.tsx @@ -0,0 +1,110 @@ +/* + * 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 { BaseEdge, type EdgeProps } from '@xyflow/react'; + +const CHEVRON_COUNT = 4; +const DURATION = 4; +const COLOR = '#818cf8'; // indigo-400 +const RADIUS = 16; // Corner radius +const CONTAINER_INSET = 14; // Px inside container left edge (aligns with decorative track) +const FALLBACK_OFFSET = 70; // Fallback left offset if no group data + +/** + * Loop-back edge for reasoning iterations. + * Routes: source (left) → left to container edge → up → right → target (left). + * Dashed indigo line with animated chevrons and "Next Iteration" label. + */ +export function LoopBackEdge({ + sourceX, + sourceY, + targetX, + targetY, + data, +}: EdgeProps) { + const groupLeftX = (data as Record | undefined) + ?.groupLeftX as number | undefined; + + // X coordinate for the vertical segment — snap to container left edge + const leftX = + groupLeftX != null + ? groupLeftX + CONTAINER_INSET + : Math.min(sourceX, targetX) - FALLBACK_OFFSET; + + const r = RADIUS; + + const edgePath = [ + // Start at source (left-bottom handle of LLM node) + `M ${sourceX} ${sourceY}`, + // Go left to container edge + `L ${leftX + r} ${sourceY}`, + // Corner: left → up + `Q ${leftX} ${sourceY} ${leftX} ${sourceY - r}`, + // Go up to target level + `L ${leftX} ${targetY + r}`, + // Corner: up → right + `Q ${leftX} ${targetY} ${leftX + r} ${targetY}`, + // Go right to target (top-left handle of iteration phase node) + `L ${targetX} ${targetY}`, + ].join(' '); + + // Label at midpoint of the vertical segment + const labelX = leftX; + const labelY = (sourceY + targetY) / 2; + + return ( + + + {/* Arrow at target end — points right (→) */} + + {/* Animated chevrons flowing along the path */} + {Array.from({ length: CHEVRON_COUNT }, (_, i) => ( + + + + ))} + {/* Label on the vertical segment */} + + + + Next Iteration + + + + ); +} diff --git a/packages/graph-ui/src/components/edges/index.ts b/packages/graph-ui/src/components/edges/index.ts new file mode 100644 index 00000000..04a73fc2 --- /dev/null +++ b/packages/graph-ui/src/components/edges/index.ts @@ -0,0 +1,19 @@ +/* + * 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 { AnimatedEdge } from './AnimatedEdge'; +import { ConditionalEdge } from './ConditionalEdge'; +import { LoopBackEdge } from './LoopBackEdge'; + +export { AnimatedEdge, ConditionalEdge, LoopBackEdge }; + +/** Edge type registry for React Flow */ +export const graphEdgeTypes = { + smoothstep: AnimatedEdge, + conditional: ConditionalEdge, + 'loop-back': LoopBackEdge, +} as const; diff --git a/packages/graph-ui/src/components/nodes/ActionNode.tsx b/packages/graph-ui/src/components/nodes/ActionNode.tsx new file mode 100644 index 00000000..e6fc862a --- /dev/null +++ b/packages/graph-ui/src/components/nodes/ActionNode.tsx @@ -0,0 +1,45 @@ +/* + * 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 type { NodeProps } from '@xyflow/react'; +import { Play } from 'lucide-react'; +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) { + const borderClass = getNodeBorderClass(selected, data.diagnostics); + + return ( +
+ + {data.diagnostics && data.diagnostics.length > 0 && ( +
+ +
+ )} +
+
+ +
+
+
+ {data.label} +
+
Action
+
+
+
+ ); +} diff --git a/packages/graph-ui/src/components/nodes/BuildInstructionsNode.tsx b/packages/graph-ui/src/components/nodes/BuildInstructionsNode.tsx new file mode 100644 index 00000000..a500f495 --- /dev/null +++ b/packages/graph-ui/src/components/nodes/BuildInstructionsNode.tsx @@ -0,0 +1,31 @@ +/* + * 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 type { NodeProps } from '@xyflow/react'; +import { Layers } from 'lucide-react'; +import type { GraphNode } from '../../ast/ast-to-graph'; +import { NodeHandles, BUILD_INSTRUCTIONS_SIDES } from './NodeHandles'; + +export function BuildInstructionsNode({ data }: NodeProps) { + return ( +
+ +
+
+ +
+
+ {data.label} +
+
+
+ ); +} diff --git a/packages/graph-ui/src/components/nodes/CompoundTopicNode.tsx b/packages/graph-ui/src/components/nodes/CompoundTopicNode.tsx new file mode 100644 index 00000000..6cce7020 --- /dev/null +++ b/packages/graph-ui/src/components/nodes/CompoundTopicNode.tsx @@ -0,0 +1,81 @@ +/* + * 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 type { NodeProps } from '@xyflow/react'; +import { Hash } from 'lucide-react'; +import type { GraphNode } from '../../ast/ast-to-graph'; +import { getNodeBorderClass } from './diagnosticBorder'; +import { DiagnosticHoverCard } from './DiagnosticHoverCard'; +import { NodeHandles } from './NodeHandles'; + +const SECTION_LABELS: Record = { + before_reasoning: 'Before Reasoning', + reasoning: 'Reasoning', + after_reasoning: 'After Reasoning', +}; + +const ALL_SECTIONS = [ + 'before_reasoning', + 'reasoning', + 'after_reasoning', +] as const; + +export function CompoundTopicNode({ data, selected }: NodeProps) { + const sections = ALL_SECTIONS; + const borderClass = getNodeBorderClass(selected, data.diagnostics); + + return ( +
+ + {data.diagnostics && data.diagnostics.length > 0 && ( +
+ +
+ )} + + {/* Header */} +
+
+ +
+
+
+ {data.label} +
+
+
+ + {/* Sections */} +
+ {sections.map((section, idx) => ( +
+ + {SECTION_LABELS[section] ?? section} + +
+ ))} +
+
+ ); +} diff --git a/packages/graph-ui/src/components/nodes/ConditionalNode.tsx b/packages/graph-ui/src/components/nodes/ConditionalNode.tsx new file mode 100644 index 00000000..26683960 --- /dev/null +++ b/packages/graph-ui/src/components/nodes/ConditionalNode.tsx @@ -0,0 +1,82 @@ +/* + * 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 { Handle, Position, type NodeProps } from '@xyflow/react'; +import { Diamond } from 'lucide-react'; +import type { GraphNode } from '../../ast/ast-to-graph'; + +export function ConditionalNode({ data }: NodeProps) { + const conditionLabel = data.conditionLabel ?? data.conditionText ?? ''; + const fullCondition = data.conditionText ?? ''; + + return ( +
+
+ {/* Single target handle — top center */} + + + {/* Compact header row */} +
+ + + {conditionLabel} + +
+ + {/* If / else indicator row */} +
+ + if + +
+ + else + +
+ + {/* "if" handle at 30% */} + + {/* "else" handle at 70% */} + +
+ + {/* Hover tooltip — full condition text */} + {fullCondition && ( +
+
+
+ Condition +
+
+ If {fullCondition} +
+
+
+ )} +
+ ); +} diff --git a/packages/graph-ui/src/components/nodes/DiagnosticHoverCard.tsx b/packages/graph-ui/src/components/nodes/DiagnosticHoverCard.tsx new file mode 100644 index 00000000..d1c65ab5 --- /dev/null +++ b/packages/graph-ui/src/components/nodes/DiagnosticHoverCard.tsx @@ -0,0 +1,103 @@ +/* + * 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 { DiagnosticSeverity } from '@agentscript/types'; +import type { Diagnostic } from '@agentscript/types'; +import { cn } from '../../utils'; +import { CircleAlert, TriangleAlert, Info } from 'lucide-react'; + +interface DiagnosticHoverCardProps { + diagnostics: Diagnostic[]; +} + +/** + * Badge + hover tooltip for graph nodes. + * + * Shows error/warning/info counts as a compact badge. + * On hover, displays a floating card listing each diagnostic message. + * Uses CSS group-hover so it works inside React Flow's transformed canvas + * (no portals or fixed positioning needed). + */ +export function DiagnosticHoverCard({ diagnostics }: DiagnosticHoverCardProps) { + if (diagnostics.length === 0) return null; + + const errors = diagnostics.filter( + d => d.severity === DiagnosticSeverity.Error + ); + const warnings = diagnostics.filter( + d => d.severity === DiagnosticSeverity.Warning + ); + const infos = diagnostics.filter( + d => + d.severity === DiagnosticSeverity.Information || + d.severity === DiagnosticSeverity.Hint + ); + + return ( +
+ {/* Badge (always visible) */} +
+ {errors.length > 0 && ( + + + {errors.length} + + )} + {warnings.length > 0 && ( + + + {warnings.length} + + )} + {infos.length > 0 && ( + + + {infos.length} + + )} +
+ + {/* Hover tooltip */} +
+
+
    + {diagnostics.map((d, i) => ( +
  • + + {d.severity === DiagnosticSeverity.Error && ( + + )} + {d.severity === DiagnosticSeverity.Warning && ( + + )} + {(d.severity === DiagnosticSeverity.Information || + d.severity === DiagnosticSeverity.Hint) && ( + + )} + + + {d.message} + +
  • + ))} +
+
+
+
+ ); +} diff --git a/packages/graph-ui/src/components/nodes/LlmNode.tsx b/packages/graph-ui/src/components/nodes/LlmNode.tsx new file mode 100644 index 00000000..6c608036 --- /dev/null +++ b/packages/graph-ui/src/components/nodes/LlmNode.tsx @@ -0,0 +1,83 @@ +/* + * 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 type { NodeProps } from '@xyflow/react'; +import { Sparkles, Play } from 'lucide-react'; +import type { GraphNode } from '../../ast/ast-to-graph'; +import { NodeHandles, LLM_SIDES } from './NodeHandles'; +import { useGraphContext } from '../../context/GraphContext'; + +export function LlmNode({ data }: NodeProps) { + const actionNames = data.actionNames as string[] | undefined; + const hasActions = actionNames && actionNames.length > 0; + const { onActionClick } = useGraphContext(); + + const handleActionClick = ( + e: React.MouseEvent, + actionName: string, + index: number + ) => { + e.stopPropagation(); + onActionClick?.({ + actionDisplayName: actionName, + actionIndex: index, + topicName: data.topicName as string | undefined, + }); + }; + + return ( +
+ + {/* Gradient background */} +
+ {/* Header */} +
+
+ +
+
+
+ {data.label} +
+ {data.subtitle && ( +
+ {data.subtitle} +
+ )} +
+
+ + {/* Action pills — interactive */} + {hasActions && ( +
+ {actionNames.map((name, index) => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/packages/graph-ui/src/components/nodes/NodeHandles.tsx b/packages/graph-ui/src/components/nodes/NodeHandles.tsx new file mode 100644 index 00000000..22b29261 --- /dev/null +++ b/packages/graph-ui/src/components/nodes/NodeHandles.tsx @@ -0,0 +1,212 @@ +/* + * 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 + */ + +/** + * Shared handle component for graph nodes. + * + * Renders one handle per side with connected/unconnected styling: + * - Unconnected: tiny, semi-transparent, barely visible + * - Connected: larger, filled with accent color, white border + */ + +import { Handle, Position, type HandleType } from '@xyflow/react'; + +// --------------------------------------------------------------------------- +// Handle ID conventions +// --------------------------------------------------------------------------- + +/** Single top handle at center */ +const TOP_HANDLES = [{ id: 'top', left: '50%' }] as const; + +/** Single bottom handle at center */ +const BOTTOM_HANDLES = [{ id: 'bottom', left: '50%' }] as const; + +/** Single left handle at center */ +const LEFT_HANDLES = [{ id: 'left', top: '50%' }] as const; + +/** Single right handle at center */ +const RIGHT_HANDLES = [{ id: 'right', top: '50%' }] as const; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type HandleSide = 'top' | 'bottom' | 'left' | 'right'; + +interface SideConfig { + type: HandleType; +} + +export interface NodeHandlesProps { + /** Which sides to render and their handle type (source/target). */ + sides: Partial>; + /** Set of handle IDs that have edges connected. */ + connectedHandles?: ReadonlySet; + /** CSS color for connected handle fill (e.g., '#3b82f6' for blue). */ + accentColor?: string; +} + +// --------------------------------------------------------------------------- +// Styling +// --------------------------------------------------------------------------- + +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-zinc-800'; + +function handleClass(connected: boolean, _accentColor?: string): string { + if (!connected) return UNCONNECTED; + // When connected, the bg color is set via inline style, so just use the base class + return CONNECTED_BASE; +} + +function handleStyle( + connected: boolean, + accentColor?: string, + positionOffset?: Record +): React.CSSProperties { + return { + ...positionOffset, + ...(connected && accentColor ? { backgroundColor: accentColor } : {}), + ...(connected && !accentColor ? { backgroundColor: '#6b7280' } : {}), + }; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export function NodeHandles({ + sides, + connectedHandles, + accentColor, +}: NodeHandlesProps) { + const isConnected = (id: string) => connectedHandles?.has(id) ?? false; + + return ( + <> + {sides.top && + TOP_HANDLES.map(h => ( + + ))} + + {sides.bottom && + BOTTOM_HANDLES.map(h => ( + + ))} + + {sides.left && + LEFT_HANDLES.map(h => ( + + ))} + + {sides.right && + RIGHT_HANDLES.map(h => ( + + ))} + + ); +} + +/** + * Standard handle sides for overview nodes (TB layout): + * targets on top/left, sources on bottom/right. + */ +export const OVERVIEW_SIDES: Partial> = { + top: { type: 'target' }, + bottom: { type: 'source' }, + left: { type: 'target' }, + right: { type: 'source' }, +}; + +/** + * Standard handle sides for detail nodes (LR layout): + * targets on left/top, sources on right/bottom. + */ +export const DETAIL_SIDES: Partial> = { + left: { type: 'target' }, + right: { type: 'source' }, + top: { type: 'target' }, + bottom: { type: 'source' }, +}; + +/** + * Start node: only source handles (bottom + right). No targets. + */ +export const START_SIDES: Partial> = { + bottom: { type: 'source' }, + right: { type: 'source' }, +}; + +/** + * Terminal node (e.g., Transition): only target handles. No sources. + */ +export const TERMINAL_SIDES: Partial> = { + top: { type: 'target' }, + left: { type: 'target' }, +}; + +/** + * Phase node handles (TB layout): target on top, source on bottom + right. + */ +export const PHASE_SIDES: Partial> = { + top: { type: 'target' }, + bottom: { type: 'source' }, + right: { type: 'source' }, +}; + +/** + * LLM node handles: top target, bottom/right source, left source (loop-back). + */ +export const LLM_SIDES: Partial> = { + top: { type: 'target' }, + bottom: { type: 'source' }, + left: { type: 'source' }, + right: { type: 'source' }, +}; + +/** + * Build Instructions: top target, bottom source. + */ +export const BUILD_INSTRUCTIONS_SIDES: Partial> = + { + top: { type: 'target' }, + bottom: { type: 'source' }, + }; diff --git a/packages/graph-ui/src/components/nodes/PhaseNode.tsx b/packages/graph-ui/src/components/nodes/PhaseNode.tsx new file mode 100644 index 00000000..ea78f310 --- /dev/null +++ b/packages/graph-ui/src/components/nodes/PhaseNode.tsx @@ -0,0 +1,154 @@ +/* + * 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 type { NodeProps } from '@xyflow/react'; +import { PlayCircle, CheckCircle, BookOpen } from 'lucide-react'; +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'; + +interface PhaseConfig { + icon: typeof PlayCircle; + color: string; + bgClass: string; + iconClass: string; +} + +const PHASE_CONFIGS: Record = { + 'topic-header': { + icon: PlayCircle, + color: '#0ea5e9', + bgClass: 'bg-sky-100 dark:bg-sky-900/40', + iconClass: 'text-sky-600 dark:text-sky-400', + }, + before_reasoning: { + icon: PlayCircle, + color: '#22c55e', + bgClass: 'bg-green-100 dark:bg-green-900/40', + iconClass: 'text-green-600 dark:text-green-400', + }, + after_reasoning: { + icon: CheckCircle, + color: '#f59e0b', + bgClass: 'bg-amber-100 dark:bg-amber-900/40', + iconClass: 'text-amber-600 dark:text-amber-400', + }, + before_reasoning_iteration: { + icon: BookOpen, + color: '#6366f1', + bgClass: 'bg-indigo-100 dark:bg-indigo-900/40', + iconClass: 'text-indigo-600 dark:text-indigo-400', + }, +}; + +const DEFAULT_CONFIG: PhaseConfig = { + icon: PlayCircle, + color: '#6b7280', + bgClass: 'bg-gray-100 dark:bg-gray-800/40', + iconClass: 'text-gray-600 dark:text-gray-400', +}; + +/** Loop-back target (before_reasoning_iteration): left side is a target for the upward arc */ +const PHASE_LOOP_TARGET_SIDES: Partial< + Record +> = { + top: { type: 'target' }, + bottom: { type: 'source' }, + right: { type: 'source' }, + left: { type: 'target' }, +}; + +function getSidesForPhase( + phaseType: PhaseType | undefined +): Partial> { + if (phaseType === 'before_reasoning_iteration') + return PHASE_LOOP_TARGET_SIDES; + return PHASE_SIDES; +} + +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; + const borderClass = getNodeBorderClass(selected, data.diagnostics); + const isLabel = data.nodeType === 'phase-label'; + const sides = getSidesForPhase(phaseType); + const isEmpty = data.isEmpty === true; + + // Empty phase: dashed border, muted — shows lifecycle slot exists + if (isEmpty) { + return ( +
+ +
+
+ +
+
+
+ {data.label} +
+ {data.subtitle && ( +
+ {data.subtitle} +
+ )} +
+
+
+ ); + } + + return ( +
+ + {data.diagnostics && data.diagnostics.length > 0 && ( +
+ +
+ )} +
+
+ +
+
+
+ {data.label} +
+ {data.subtitle && ( +
+ {data.subtitle} +
+ )} +
+
+
+ ); +} diff --git a/packages/graph-ui/src/components/nodes/ReasoningGroupNode.tsx b/packages/graph-ui/src/components/nodes/ReasoningGroupNode.tsx new file mode 100644 index 00000000..e123c88a --- /dev/null +++ b/packages/graph-ui/src/components/nodes/ReasoningGroupNode.tsx @@ -0,0 +1,158 @@ +/* + * 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 type { NodeProps } from '@xyflow/react'; +import { Handle, Position } from '@xyflow/react'; +import { ArrowDown, RotateCw } from 'lucide-react'; +import type { GraphNode } from '../../ast/ast-to-graph'; + +/** + * Visual container node for phase groups (uses React Flow parentId nesting). + * + * All variants have invisible React Flow handles (t-c, enter-out, exit-in, b-c) + * so spine edges can route through the group boundary. + * + * Reasoning Loop: prominent solid border, gradient bg, "iterates" badge, + * "Enter Loop" handle/badge at top, "Exit" handle/badge at bottom. + * Before/After Reasoning: lighter gray container for subtle grouping. + * Empty state: dashed border, muted, no content. + * + * Dimensions come from node.style (set by the layout engine). The component + * fills its container with `width: 100%, height: 100%`. + * + * Non-interactive — purely visual grouping. + */ +export function ReasoningGroupNode({ data }: NodeProps) { + const isLoop = data.label === 'Reasoning Loop'; + const isEmpty = data.isEmpty === true; + + const containerStyle: React.CSSProperties = { + width: '100%', + height: '100%', + }; + + /** Invisible handle style — handles are hidden, edges connect through them */ + const hiddenHandle: React.CSSProperties = { + width: 1, + height: 1, + opacity: 0, + pointerEvents: 'none', + }; + + // Shared handles for ALL group variants (spine edges route through these) + const groupHandles = ( + <> + {/* External target at top */} + + {/* Internal source at top — edge goes DOWN to first child */} + + {/* Internal target at bottom — edge arrives from last child */} + + {/* External source at bottom */} + + + ); + + // Empty before/after reasoning: dashed container with visible border + if (isEmpty && !isLoop) { + return ( +
+ {groupHandles} +
+ + + {data.label} + +
+
+ ); + } + + return ( +
+ {groupHandles} + + {/* Enter Loop badge — top center (Reasoning Loop only) */} + {isLoop && ( +
+ + Enter Loop +
+ )} + + {/* Exit badge — bottom center (Reasoning Loop only) */} + {isLoop && ( +
+ + Exit +
+ )} + + {/* Header */} +
+
+ + + {data.label} + +
+ {isLoop && ( + + iterates + + )} +
+
+ ); +} diff --git a/packages/graph-ui/src/components/nodes/RunNode.tsx b/packages/graph-ui/src/components/nodes/RunNode.tsx new file mode 100644 index 00000000..1178383f --- /dev/null +++ b/packages/graph-ui/src/components/nodes/RunNode.tsx @@ -0,0 +1,39 @@ +/* + * 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 type { NodeProps } from '@xyflow/react'; +import { Play } from 'lucide-react'; +import type { GraphNode } from '../../ast/ast-to-graph'; +import { getNodeBorderClass } from './diagnosticBorder'; +import { NodeHandles, DETAIL_SIDES } from './NodeHandles'; + +export function RunNode({ data, selected }: NodeProps) { + const borderClass = getNodeBorderClass(selected, data.diagnostics); + + return ( +
+ +
+
+ +
+
+
+ {data.label} +
+
Run
+
+
+
+ ); +} diff --git a/packages/graph-ui/src/components/nodes/SetNode.tsx b/packages/graph-ui/src/components/nodes/SetNode.tsx new file mode 100644 index 00000000..80c4761e --- /dev/null +++ b/packages/graph-ui/src/components/nodes/SetNode.tsx @@ -0,0 +1,41 @@ +/* + * 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 type { NodeProps } from '@xyflow/react'; +import { Equal } from 'lucide-react'; +import type { GraphNode } from '../../ast/ast-to-graph'; +import { getNodeBorderClass } from './diagnosticBorder'; +import { NodeHandles, DETAIL_SIDES } from './NodeHandles'; + +export function SetNode({ data, selected }: NodeProps) { + const borderClass = getNodeBorderClass(selected, data.diagnostics); + + return ( +
+ +
+
+ +
+
+
+ {data.label} +
+
+ = {data.subtitle} +
+
+
+
+ ); +} diff --git a/packages/graph-ui/src/components/nodes/StartNode.tsx b/packages/graph-ui/src/components/nodes/StartNode.tsx new file mode 100644 index 00000000..7c9e169c --- /dev/null +++ b/packages/graph-ui/src/components/nodes/StartNode.tsx @@ -0,0 +1,42 @@ +/* + * 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 type { NodeProps } from '@xyflow/react'; +import { Play } from 'lucide-react'; +import type { GraphNode } from '../../ast/ast-to-graph'; +import { GRAPH } from '../../tokens/graph-tokens'; +import { NodeHandles, START_SIDES } from './NodeHandles'; + +export function StartNode({ data, selected }: NodeProps) { + return ( +
+ +
+ +
+ + Start + +
+ ); +} diff --git a/packages/graph-ui/src/components/nodes/TemplateNode.tsx b/packages/graph-ui/src/components/nodes/TemplateNode.tsx new file mode 100644 index 00000000..1a2c0906 --- /dev/null +++ b/packages/graph-ui/src/components/nodes/TemplateNode.tsx @@ -0,0 +1,39 @@ +/* + * 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 type { NodeProps } from '@xyflow/react'; +import { AlignLeft } from 'lucide-react'; +import type { GraphNode } from '../../ast/ast-to-graph'; +import { getNodeBorderClass } from './diagnosticBorder'; +import { NodeHandles, DETAIL_SIDES } from './NodeHandles'; + +export function TemplateNode({ data, selected }: NodeProps) { + const text = data.label ?? ''; + const borderClass = getNodeBorderClass(selected, data.diagnostics); + + return ( +
+ +
+
+ +
+
+
+ {text} +
+
+
+
+ ); +} diff --git a/packages/graph-ui/src/components/nodes/TopicNode.tsx b/packages/graph-ui/src/components/nodes/TopicNode.tsx new file mode 100644 index 00000000..0d63f10d --- /dev/null +++ b/packages/graph-ui/src/components/nodes/TopicNode.tsx @@ -0,0 +1,71 @@ +/* + * 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 type { NodeProps } from '@xyflow/react'; +import { ChevronRight } from 'lucide-react'; +import type { GraphNode } from '../../ast/ast-to-graph'; +import { getBlockTypeConfig } from '../../tokens/block-type-config'; +import { GRAPH } from '../../tokens/graph-tokens'; +import { getNodeBorderClass } from './diagnosticBorder'; +import { DiagnosticHoverCard } from './DiagnosticHoverCard'; +import { NodeHandles, OVERVIEW_SIDES } from './NodeHandles'; + +export function TopicNode({ data, selected }: NodeProps) { + const isStartAgent = !!data.isStartAgent; + const config = getBlockTypeConfig(data.blockType, { + isStartAgent, + iconSize: 20, + }); + const diagnosticClass = getNodeBorderClass(selected, data.diagnostics); + const hasDiagnosticBorder = diagnosticClass !== ''; + + const accentColor = isStartAgent ? GRAPH.intelligence.accent : '#38bdf8'; // sky-400 — topics are inviting destinations + + // Start Agent: indigo-tinted — it routes conversations (intelligence color) + // 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-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 ( +
+ + {data.diagnostics && data.diagnostics.length > 0 && ( +
+ +
+ )} +
+
+ {config.icon} +
+
+
+ {data.label} +
+
+ {data.subtitle ?? (isStartAgent ? 'Start Agent' : 'Topic')} +
+
+ +
+
+ ); +} diff --git a/packages/graph-ui/src/components/nodes/TransitionNode.tsx b/packages/graph-ui/src/components/nodes/TransitionNode.tsx new file mode 100644 index 00000000..98550030 --- /dev/null +++ b/packages/graph-ui/src/components/nodes/TransitionNode.tsx @@ -0,0 +1,44 @@ +/* + * 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 type { NodeProps } from '@xyflow/react'; +import { ArrowRightCircle } from 'lucide-react'; +import type { GraphNode } from '../../ast/ast-to-graph'; +import { getNodeBorderClass } from './diagnosticBorder'; +import { NodeHandles, TERMINAL_SIDES } from './NodeHandles'; + +export function TransitionNode({ data, selected }: NodeProps) { + const borderClass = getNodeBorderClass(selected, data.diagnostics); + + return ( +
+ +
+
+ +
+
+
+ {data.label} +
+
+ Transition +
+
+
+
+ ); +} diff --git a/packages/graph-ui/src/components/nodes/diagnosticBorder.ts b/packages/graph-ui/src/components/nodes/diagnosticBorder.ts new file mode 100644 index 00000000..db1a747c --- /dev/null +++ b/packages/graph-ui/src/components/nodes/diagnosticBorder.ts @@ -0,0 +1,36 @@ +/* + * 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 { DiagnosticSeverity, type Diagnostic } from '@agentscript/types'; + +/** + * Compute the border/ring classes for a graph node based on + * its selection state and diagnostics. + */ +export function getNodeBorderClass( + selected: boolean | undefined, + diagnostics: Diagnostic[] | undefined +): string { + if (selected) { + return 'border-blue-400 ring-2 ring-blue-200 shadow-lg shadow-blue-500/10 dark:ring-blue-800 dark:shadow-blue-500/8'; + } + if (diagnostics && diagnostics.length > 0) { + const hasError = diagnostics.some( + d => d.severity === DiagnosticSeverity.Error + ); + if (hasError) { + return 'border-red-400 ring-2 ring-red-400/20 dark:border-red-500 dark:ring-red-500/20'; + } + const hasWarning = diagnostics.some( + d => d.severity === DiagnosticSeverity.Warning + ); + if (hasWarning) { + return 'border-amber-400 ring-2 ring-amber-400/20 dark:border-amber-500 dark:ring-amber-500/20'; + } + } + return ''; +} diff --git a/packages/graph-ui/src/components/nodes/index.ts b/packages/graph-ui/src/components/nodes/index.ts new file mode 100644 index 00000000..3ebe0458 --- /dev/null +++ b/packages/graph-ui/src/components/nodes/index.ts @@ -0,0 +1,54 @@ +/* + * 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 { StartNode } from './StartNode'; +import { TopicNode } from './TopicNode'; +import { ActionNode } from './ActionNode'; +import { CompoundTopicNode } from './CompoundTopicNode'; +import { ConditionalNode } from './ConditionalNode'; +import { TransitionNode } from './TransitionNode'; +import { RunNode } from './RunNode'; +import { SetNode } from './SetNode'; +import { TemplateNode } from './TemplateNode'; +import { PhaseNode } from './PhaseNode'; +import { LlmNode } from './LlmNode'; +import { ReasoningGroupNode } from './ReasoningGroupNode'; +import { BuildInstructionsNode } from './BuildInstructionsNode'; +export { + StartNode, + TopicNode, + ActionNode, + CompoundTopicNode, + ConditionalNode, + TransitionNode, + RunNode, + SetNode, + TemplateNode, + PhaseNode, + LlmNode, + ReasoningGroupNode, + BuildInstructionsNode, +}; + +/** Node type registry for React Flow */ +export const graphNodeTypes = { + start: StartNode, + 'start-agent': TopicNode, + topic: TopicNode, + action: ActionNode, + 'compound-topic': CompoundTopicNode, + conditional: ConditionalNode, + transition: TransitionNode, + run: RunNode, + set: SetNode, + template: TemplateNode, + phase: PhaseNode, + 'phase-label': PhaseNode, + llm: LlmNode, + 'reasoning-group': ReasoningGroupNode, + 'build-instructions': BuildInstructionsNode, +} as const; diff --git a/packages/graph-ui/src/context/GraphContext.tsx b/packages/graph-ui/src/context/GraphContext.tsx new file mode 100644 index 00000000..2f3a32a3 --- /dev/null +++ b/packages/graph-ui/src/context/GraphContext.tsx @@ -0,0 +1,50 @@ +/* + * 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 { createContext, useContext, type ReactNode } from 'react'; + +export interface ActionClickPayload { + actionDisplayName: string; + actionIndex: number; + topicName: string | undefined; +} + +export interface ConditionalClickPayload { + edgeId: string; + conditionText: string; + sourceTopicName: string; + conditionalKey: string; +} + +export interface GraphContextValue { + /** Edge IDs to highlight on the current path; null when no selection. */ + highlightedEdgeIds: Set | 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 00000000..d6cef2e9 --- /dev/null +++ b/packages/graph-ui/src/index.ts @@ -0,0 +1,53 @@ +/* + * 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, +} 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/packages/graph-ui/src/tokens/block-type-config.tsx b/packages/graph-ui/src/tokens/block-type-config.tsx new file mode 100644 index 00000000..7c80c504 --- /dev/null +++ b/packages/graph-ui/src/tokens/block-type-config.tsx @@ -0,0 +1,154 @@ +/* + * 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 type { ReactNode } from 'react'; +import { + Settings, + Cog, + Variable, + Play, + BookOpen, + Link, + Hash, + GitPullRequest, + Languages, + Users, +} from 'lucide-react'; + +export interface BlockTypeConfig { + icon: ReactNode; + iconBg: string; + iconClassName: string; + subtitle?: string; +} + +/** + * Single source of truth for block type visual configuration + * Used by: Canvas graph nodes, Explorer tree, BlockNote headers + */ +export function getBlockTypeConfig( + blockType: string, + options?: { + isStartAgent?: boolean; + iconSize?: number; + } +): BlockTypeConfig { + const { isStartAgent = false, iconSize = 16 } = options || {}; + + // Special case: start_agent — uses intelligence (indigo) color, it makes routing decisions + if ( + (blockType === 'topic' || + blockType === 'start_agent' || + blockType === 'subagent') && + isStartAgent + ) { + return { + icon: ( + + ), + iconClassName: 'text-indigo-500 dark:text-indigo-400', + iconBg: 'rgba(99,102,241,0.40)', + subtitle: 'Start Agent', + }; + } + + // Block type configurations + const configs: Record< + string, + Omit & { iconComponent: typeof Hash } + > = { + system: { + iconComponent: Settings, + iconClassName: 'text-blue-600', + iconBg: '#dbeafe', + }, + config: { + iconComponent: Cog, + iconClassName: 'text-purple-600', + iconBg: '#f3e8ff', + }, + variables: { + iconComponent: Variable, + iconClassName: 'text-orange-600', + iconBg: '#fed7aa', + }, + actions: { + iconComponent: Play, + iconClassName: 'text-green-600', + iconBg: '#d1fae5', + }, + knowledge: { + iconComponent: BookOpen, + iconClassName: 'text-teal-600', + iconBg: '#ccfbf1', + subtitle: 'Knowledge', + }, + knowledge_action: { + iconComponent: BookOpen, + iconClassName: 'text-teal-600', + iconBg: '#ccfbf1', + }, + language: { + iconComponent: Languages, + iconClassName: 'text-indigo-600', + iconBg: '#e0e7ff', + }, + connection: { + iconComponent: Link, + iconClassName: 'text-cyan-600', + iconBg: '#cffafe', + subtitle: 'Connection', + }, + topic: { + iconComponent: Hash, + iconClassName: 'text-sky-500 dark:text-sky-400', + iconBg: 'rgba(14,165,233,0.35)', + subtitle: 'Topic', + }, + subagent: { + iconComponent: Hash, + iconClassName: 'text-sky-500 dark:text-sky-400', + iconBg: 'rgba(14,165,233,0.35)', + subtitle: 'Subagent', + }, + start_agent: { + iconComponent: GitPullRequest, + iconClassName: 'text-indigo-500 dark:text-indigo-400', + iconBg: 'rgba(99,102,241,0.40)', + subtitle: 'Start Agent', + }, + related_agent: { + iconComponent: Users, + iconClassName: 'text-rose-600', + iconBg: '#ffe4e6', + subtitle: 'Related Agent', + }, + }; + + const config = configs[blockType]; + + if (config) { + const IconComponent = config.iconComponent; + return { + icon: , + iconClassName: config.iconClassName, + iconBg: config.iconBg, + subtitle: config.subtitle, + }; + } + + // Default fallback + return { + icon: , + iconClassName: 'text-gray-600', + iconBg: '#f3f4f6', + subtitle: blockType, + }; +} diff --git a/packages/graph-ui/src/tokens/graph-tokens.ts b/packages/graph-ui/src/tokens/graph-tokens.ts new file mode 100644 index 00000000..23259f3f --- /dev/null +++ b/packages/graph-ui/src/tokens/graph-tokens.ts @@ -0,0 +1,47 @@ +/* + * 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 + */ + +/** + * Centralized design tokens for graph components. + * + * Three semantic color roles: + * Intelligence (indigo) — reasoning, LLM, decisions, conditions + * Action (green) — start, run, execute + * Structure (slate) — topics, phases, containers, variables, transitions + */ +export const GRAPH = { + intelligence: { + accent: '#818cf8', + bg: 'rgba(99,102,241,0.15)', + text: '#c7d2fe', + }, + action: { + accent: '#4ade80', + bg: 'rgba(74,222,128,0.25)', + text: '#bbf7d0', + }, + structure: { + accent: '#94a3b8', + bg: 'rgba(148,163,184,0.10)', + text: '#cbd5e1', + }, + node: { + bg: '#26262e', + border: '#505060', + borderHover: '#606070', + }, + edge: { + primary: '#6366f1', + secondary: '#64748b', + highlight: '#3b82f6', + }, + text: { + primary: '#f1f5f9', + secondary: '#94a3b8', + tertiary: '#64748b', + }, +} as const; diff --git a/packages/graph-ui/src/utils.ts b/packages/graph-ui/src/utils.ts new file mode 100644 index 00000000..e84921e8 --- /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 00000000..18d52a74 --- /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"] +} From c863626e284d95d5344aa60e908e559e48bd142b Mon Sep 17 00:00:00 2001 From: Marcelino Llano Date: Mon, 4 May 2026 19:48:31 +0100 Subject: [PATCH 2/6] refactor(apps/ui): consume @agentscript/graph-ui Replaces the local graph rendering (nodes, edges, AST transforms, layout, tokens) with imports from the shared package. The Graph page becomes a thin wrapper that injects host-specific concerns (router, drawer, zustand store) via props and context. - Graph.tsx passes topicId/theme/callbacks to from graph-ui - Drawer stays in apps/ui, opened from onNodeClick / onConditionalClick - Explorer, Builder, drawer views import shared types from graph-ui - Tailwind @source directive picks up node classes from the package --- apps/ui/package.json | 1 + apps/ui/src/components/explorer/TreeView.tsx | 2 +- .../components/graph/ActionDrawerContent.tsx | 2 +- .../graph/ConditionalBuilderView.tsx | 6 +- .../components/graph/ConditionalCodeView.tsx | 6 +- .../graph/ConditionalDrawerContent.tsx | 2 +- .../components/graph/NodeDrawerContent.tsx | 2 +- .../components/graph/edges/AnimatedEdge.tsx | 131 -- .../graph/edges/ConditionalEdge.tsx | 146 -- .../components/graph/edges/LoopBackEdge.tsx | 110 -- apps/ui/src/components/graph/edges/index.ts | 19 - .../src/components/graph/nodes/ActionNode.tsx | 45 - .../graph/nodes/BuildInstructionsNode.tsx | 31 - .../graph/nodes/CompoundTopicNode.tsx | 84 - .../graph/nodes/ConditionalNode.tsx | 82 - .../graph/nodes/DiagnosticHoverCard.tsx | 103 -- .../ui/src/components/graph/nodes/LlmNode.tsx | 83 - .../components/graph/nodes/NodeHandles.tsx | 219 --- .../src/components/graph/nodes/PhaseNode.tsx | 154 -- .../graph/nodes/ReasoningGroupNode.tsx | 158 -- .../ui/src/components/graph/nodes/RunNode.tsx | 39 - .../ui/src/components/graph/nodes/SetNode.tsx | 41 - .../src/components/graph/nodes/StartNode.tsx | 42 - .../components/graph/nodes/TemplateNode.tsx | 39 - .../src/components/graph/nodes/TopicNode.tsx | 71 - .../components/graph/nodes/TransitionNode.tsx | 44 - .../graph/nodes/diagnosticBorder.ts | 36 - apps/ui/src/components/graph/nodes/index.ts | 54 - apps/ui/src/index.css | 2 + apps/ui/src/lib/ast-to-graph.ts | 1407 ----------------- apps/ui/src/lib/ast-utils.ts | 23 - apps/ui/src/lib/block-type-config.tsx | 154 -- apps/ui/src/lib/graph-layout.ts | 874 ---------- apps/ui/src/lib/graph-path.ts | 87 - apps/ui/src/lib/graph-tokens.ts | 47 - apps/ui/src/pages/Builder.tsx | 2 +- apps/ui/src/pages/Graph.tsx | 346 +--- apps/ui/src/store/layout.ts | 5 +- 38 files changed, 101 insertions(+), 4598 deletions(-) delete mode 100644 apps/ui/src/components/graph/edges/AnimatedEdge.tsx delete mode 100644 apps/ui/src/components/graph/edges/ConditionalEdge.tsx delete mode 100644 apps/ui/src/components/graph/edges/LoopBackEdge.tsx delete mode 100644 apps/ui/src/components/graph/edges/index.ts delete mode 100644 apps/ui/src/components/graph/nodes/ActionNode.tsx delete mode 100644 apps/ui/src/components/graph/nodes/BuildInstructionsNode.tsx delete mode 100644 apps/ui/src/components/graph/nodes/CompoundTopicNode.tsx delete mode 100644 apps/ui/src/components/graph/nodes/ConditionalNode.tsx delete mode 100644 apps/ui/src/components/graph/nodes/DiagnosticHoverCard.tsx delete mode 100644 apps/ui/src/components/graph/nodes/LlmNode.tsx delete mode 100644 apps/ui/src/components/graph/nodes/NodeHandles.tsx delete mode 100644 apps/ui/src/components/graph/nodes/PhaseNode.tsx delete mode 100644 apps/ui/src/components/graph/nodes/ReasoningGroupNode.tsx delete mode 100644 apps/ui/src/components/graph/nodes/RunNode.tsx delete mode 100644 apps/ui/src/components/graph/nodes/SetNode.tsx delete mode 100644 apps/ui/src/components/graph/nodes/StartNode.tsx delete mode 100644 apps/ui/src/components/graph/nodes/TemplateNode.tsx delete mode 100644 apps/ui/src/components/graph/nodes/TopicNode.tsx delete mode 100644 apps/ui/src/components/graph/nodes/TransitionNode.tsx delete mode 100644 apps/ui/src/components/graph/nodes/diagnosticBorder.ts delete mode 100644 apps/ui/src/components/graph/nodes/index.ts delete mode 100644 apps/ui/src/lib/ast-to-graph.ts delete mode 100644 apps/ui/src/lib/ast-utils.ts delete mode 100644 apps/ui/src/lib/block-type-config.tsx delete mode 100644 apps/ui/src/lib/graph-layout.ts delete mode 100644 apps/ui/src/lib/graph-path.ts delete mode 100644 apps/ui/src/lib/graph-tokens.ts diff --git a/apps/ui/package.json b/apps/ui/package.json index 4340a21b..979c8627 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 76873f97..59767e56 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 87a1159f..112722d3 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 923c1d45..b50b7747 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 314900a3..374cbde3 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 99fb6eb4..2d148192 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 e83bc9b9..f1d8d50a 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/components/graph/edges/AnimatedEdge.tsx b/apps/ui/src/components/graph/edges/AnimatedEdge.tsx deleted file mode 100644 index 04ad1540..00000000 --- a/apps/ui/src/components/graph/edges/AnimatedEdge.tsx +++ /dev/null @@ -1,131 +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 { BaseEdge, getSmoothStepPath, type EdgeProps } from '@xyflow/react'; -import { useAppStore } from '~/store'; - -const CHEVRON_COUNT = 2; -const DURATION = 2.4; -const HIGHLIGHT_COLOR = '#3b82f6'; -const SPINE_COLOR = '#6366f1'; // indigo-500 -const SECONDARY_COLOR = '#64748b'; // slate-500 — visible on #141414 canvas - -/** - * Default edge with animated chevrons flowing source→target. - * Uses ELK-computed routes when available for proper edge spacing. - * Reads highlight state from the Zustand store (bypasses React Flow memoization). - */ -export function AnimatedEdge({ - id, - sourceX, - sourceY, - targetX, - targetY, - sourcePosition, - targetPosition, - style, - markerEnd, - data, -}: EdgeProps) { - // Always use getSmoothStepPath for guaranteed right-angle (orthogonal) routing - const [edgePath] = getSmoothStepPath({ - sourceX, - sourceY, - targetX, - targetY, - sourcePosition, - targetPosition, - }); - - // Determine edge role for visual hierarchy - const edgeData = data as Record | undefined; - const edgeRole = edgeData?.edgeRole as string | undefined; - const isSpine = edgeRole === 'spine'; - - const highlightedEdgeIds = useAppStore( - state => state.layout.highlightedEdgeIds - ); - const isHighlighted = highlightedEdgeIds?.has(id) ?? false; - const isDimmed = highlightedEdgeIds != null && !isHighlighted; - - // Visual hierarchy: two tiers — primary (spine) and secondary (everything else) - let strokeColor: string; - let strokeWidth: number; - let chevronColor: string; - let chevronOpacity: number; - let chevronSize: string; - let glowFilter: string | undefined; - - if (isHighlighted) { - strokeColor = HIGHLIGHT_COLOR; - strokeWidth = 3; - chevronColor = HIGHLIGHT_COLOR; - chevronOpacity = 0.9; - chevronSize = '0,-5 8,0 0,5'; - glowFilter = 'url(#edge-glow)'; - } else if (isSpine) { - strokeColor = SPINE_COLOR; - strokeWidth = 2; - chevronColor = SPINE_COLOR; - chevronOpacity = 0.7; - chevronSize = '0,-4 7,0 0,4'; - } else { - strokeColor = (style?.stroke as string) ?? SECONDARY_COLOR; - strokeWidth = 2; - chevronColor = SECONDARY_COLOR; - chevronOpacity = 0.6; - chevronSize = '0,-4 7,0 0,4'; - } - - const groupOpacity = isDimmed ? 0.1 : 1; - - return ( - - {/* Glow filter definition for highlighted edges */} - - - - - - - - - - - {Array.from({ length: CHEVRON_COUNT }, (_, i) => ( - - - - ))} - - ); -} diff --git a/apps/ui/src/components/graph/edges/ConditionalEdge.tsx b/apps/ui/src/components/graph/edges/ConditionalEdge.tsx deleted file mode 100644 index 95babbfb..00000000 --- a/apps/ui/src/components/graph/edges/ConditionalEdge.tsx +++ /dev/null @@ -1,146 +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 { - BaseEdge, - EdgeLabelRenderer, - getSmoothStepPath, - type EdgeProps, -} from '@xyflow/react'; -import { ShieldCheck } from 'lucide-react'; -import { useAppStore } from '~/store'; -import type { ConditionalEdgeData } from '~/lib/ast-to-graph'; - -const COLOR = '#6366f1'; // indigo — intelligence color for decision paths -const HIGHLIGHT_COLOR = '#3b82f6'; -const CHEVRON_COUNT = 2; -const DURATION = 2.4; - -/** - * Conditional edge with flowing chevrons and a clickable gate icon. - * Uses ELK-computed routes when available for proper edge spacing. - * Hover over the gate icon to see condition text; click to open the drawer. - * Reads highlight state from the Zustand store (bypasses React Flow memoization). - */ -export function ConditionalEdge({ - id, - sourceX, - sourceY, - targetX, - targetY, - sourcePosition, - targetPosition, - label, - style, - markerEnd, - data, -}: EdgeProps) { - // Always use getSmoothStepPath for guaranteed right-angle (orthogonal) routing - const [edgePath, labelX, labelY] = getSmoothStepPath({ - sourceX, - sourceY, - targetX, - targetY, - sourcePosition, - targetPosition, - }); - - const highlightedEdgeIds = useAppStore( - state => state.layout.highlightedEdgeIds - ); - const isHighlighted = highlightedEdgeIds?.has(id) ?? false; - const isDimmed = highlightedEdgeIds != null && !isHighlighted; - - const strokeColor = isHighlighted - ? HIGHLIGHT_COLOR - : ((style?.stroke as string) ?? COLOR); - const strokeWidth = isHighlighted ? 3 : 1.5; - 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, - }, - }); - } - }; - - return ( - - - - {/* Flowing chevrons */} - {Array.from({ length: CHEVRON_COUNT }, (_, i) => ( - - - - ))} - - {/* Gate icon with hover tooltip + click to open drawer */} - {label && ( - -
-
- -
-
-
- {label} -
-
-
-
- )} -
- ); -} diff --git a/apps/ui/src/components/graph/edges/LoopBackEdge.tsx b/apps/ui/src/components/graph/edges/LoopBackEdge.tsx deleted file mode 100644 index 83422370..00000000 --- a/apps/ui/src/components/graph/edges/LoopBackEdge.tsx +++ /dev/null @@ -1,110 +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 { BaseEdge, type EdgeProps } from '@xyflow/react'; - -const CHEVRON_COUNT = 4; -const DURATION = 4; -const COLOR = '#818cf8'; // indigo-400 -const RADIUS = 16; // Corner radius -const CONTAINER_INSET = 14; // Px inside container left edge (aligns with decorative track) -const FALLBACK_OFFSET = 70; // Fallback left offset if no group data - -/** - * Loop-back edge for reasoning iterations. - * Routes: source (left) → left to container edge → up → right → target (left). - * Dashed indigo line with animated chevrons and "Next Iteration" label. - */ -export function LoopBackEdge({ - sourceX, - sourceY, - targetX, - targetY, - data, -}: EdgeProps) { - const groupLeftX = (data as Record | undefined) - ?.groupLeftX as number | undefined; - - // X coordinate for the vertical segment — snap to container left edge - const leftX = - groupLeftX != null - ? groupLeftX + CONTAINER_INSET - : Math.min(sourceX, targetX) - FALLBACK_OFFSET; - - const r = RADIUS; - - const edgePath = [ - // Start at source (left-bottom handle of LLM node) - `M ${sourceX} ${sourceY}`, - // Go left to container edge - `L ${leftX + r} ${sourceY}`, - // Corner: left → up - `Q ${leftX} ${sourceY} ${leftX} ${sourceY - r}`, - // Go up to target level - `L ${leftX} ${targetY + r}`, - // Corner: up → right - `Q ${leftX} ${targetY} ${leftX + r} ${targetY}`, - // Go right to target (top-left handle of iteration phase node) - `L ${targetX} ${targetY}`, - ].join(' '); - - // Label at midpoint of the vertical segment - const labelX = leftX; - const labelY = (sourceY + targetY) / 2; - - return ( - - - {/* Arrow at target end — points right (→) */} - - {/* Animated chevrons flowing along the path */} - {Array.from({ length: CHEVRON_COUNT }, (_, i) => ( - - - - ))} - {/* Label on the vertical segment */} - - - - Next Iteration - - - - ); -} diff --git a/apps/ui/src/components/graph/edges/index.ts b/apps/ui/src/components/graph/edges/index.ts deleted file mode 100644 index 04a73fc2..00000000 --- a/apps/ui/src/components/graph/edges/index.ts +++ /dev/null @@ -1,19 +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 { AnimatedEdge } from './AnimatedEdge'; -import { ConditionalEdge } from './ConditionalEdge'; -import { LoopBackEdge } from './LoopBackEdge'; - -export { AnimatedEdge, ConditionalEdge, LoopBackEdge }; - -/** Edge type registry for React Flow */ -export const graphEdgeTypes = { - smoothstep: AnimatedEdge, - conditional: ConditionalEdge, - 'loop-back': LoopBackEdge, -} as const; diff --git a/apps/ui/src/components/graph/nodes/ActionNode.tsx b/apps/ui/src/components/graph/nodes/ActionNode.tsx deleted file mode 100644 index 40b5cc90..00000000 --- a/apps/ui/src/components/graph/nodes/ActionNode.tsx +++ /dev/null @@ -1,45 +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 type { NodeProps } from '@xyflow/react'; -import { Play } from 'lucide-react'; -import type { GraphNodeData } from '~/lib/ast-to-graph'; -import { getNodeBorderClass } from './diagnosticBorder'; -import { DiagnosticHoverCard } from './DiagnosticHoverCard'; -import { NodeHandles, DETAIL_SIDES } from './NodeHandles'; - -export function ActionNode({ data, selected }: NodeProps) { - const borderClass = getNodeBorderClass(selected, data.diagnostics); - - return ( -
- - {data.diagnostics && data.diagnostics.length > 0 && ( -
- -
- )} -
-
- -
-
-
- {data.label} -
-
Action
-
-
-
- ); -} diff --git a/apps/ui/src/components/graph/nodes/BuildInstructionsNode.tsx b/apps/ui/src/components/graph/nodes/BuildInstructionsNode.tsx deleted file mode 100644 index 7bc6ae10..00000000 --- a/apps/ui/src/components/graph/nodes/BuildInstructionsNode.tsx +++ /dev/null @@ -1,31 +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 type { NodeProps } from '@xyflow/react'; -import { Layers } from 'lucide-react'; -import type { GraphNodeData } from '~/lib/ast-to-graph'; -import { NodeHandles, BUILD_INSTRUCTIONS_SIDES } from './NodeHandles'; - -export function BuildInstructionsNode({ data }: NodeProps) { - return ( -
- -
-
- -
-
- {data.label} -
-
-
- ); -} diff --git a/apps/ui/src/components/graph/nodes/CompoundTopicNode.tsx b/apps/ui/src/components/graph/nodes/CompoundTopicNode.tsx deleted file mode 100644 index 1b28529d..00000000 --- a/apps/ui/src/components/graph/nodes/CompoundTopicNode.tsx +++ /dev/null @@ -1,84 +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 type { NodeProps } from '@xyflow/react'; -import { Hash } from 'lucide-react'; -import type { GraphNodeData } from '~/lib/ast-to-graph'; -import { getNodeBorderClass } from './diagnosticBorder'; -import { DiagnosticHoverCard } from './DiagnosticHoverCard'; -import { NodeHandles } from './NodeHandles'; - -const SECTION_LABELS: Record = { - before_reasoning: 'Before Reasoning', - reasoning: 'Reasoning', - after_reasoning: 'After Reasoning', -}; - -const ALL_SECTIONS = [ - 'before_reasoning', - 'reasoning', - 'after_reasoning', -] as const; - -export function CompoundTopicNode({ - data, - selected, -}: NodeProps) { - const sections = ALL_SECTIONS; - const borderClass = getNodeBorderClass(selected, data.diagnostics); - - return ( -
- - {data.diagnostics && data.diagnostics.length > 0 && ( -
- -
- )} - - {/* Header */} -
-
- -
-
-
- {data.label} -
-
-
- - {/* Sections */} -
- {sections.map((section, idx) => ( -
- - {SECTION_LABELS[section] ?? section} - -
- ))} -
-
- ); -} diff --git a/apps/ui/src/components/graph/nodes/ConditionalNode.tsx b/apps/ui/src/components/graph/nodes/ConditionalNode.tsx deleted file mode 100644 index aa01ac45..00000000 --- a/apps/ui/src/components/graph/nodes/ConditionalNode.tsx +++ /dev/null @@ -1,82 +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 { Handle, Position, type NodeProps } from '@xyflow/react'; -import { Diamond } from 'lucide-react'; -import type { GraphNodeData } from '~/lib/ast-to-graph'; - -export function ConditionalNode({ data }: NodeProps) { - const conditionLabel = data.conditionLabel ?? data.conditionText ?? ''; - const fullCondition = data.conditionText ?? ''; - - return ( -
-
- {/* Single target handle — top center */} - - - {/* Compact header row */} -
- - - {conditionLabel} - -
- - {/* If / else indicator row */} -
- - if - -
- - else - -
- - {/* "if" handle at 30% */} - - {/* "else" handle at 70% */} - -
- - {/* Hover tooltip — full condition text */} - {fullCondition && ( -
-
-
- Condition -
-
- If {fullCondition} -
-
-
- )} -
- ); -} diff --git a/apps/ui/src/components/graph/nodes/DiagnosticHoverCard.tsx b/apps/ui/src/components/graph/nodes/DiagnosticHoverCard.tsx deleted file mode 100644 index 0939291b..00000000 --- a/apps/ui/src/components/graph/nodes/DiagnosticHoverCard.tsx +++ /dev/null @@ -1,103 +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 { DiagnosticSeverity } from '@agentscript/types'; -import type { Diagnostic } from '@agentscript/types'; -import { cn } from '~/lib/utils'; -import { CircleAlert, TriangleAlert, Info } from 'lucide-react'; - -interface DiagnosticHoverCardProps { - diagnostics: Diagnostic[]; -} - -/** - * Badge + hover tooltip for graph nodes. - * - * Shows error/warning/info counts as a compact badge. - * On hover, displays a floating card listing each diagnostic message. - * Uses CSS group-hover so it works inside React Flow's transformed canvas - * (no portals or fixed positioning needed). - */ -export function DiagnosticHoverCard({ diagnostics }: DiagnosticHoverCardProps) { - if (diagnostics.length === 0) return null; - - const errors = diagnostics.filter( - d => d.severity === DiagnosticSeverity.Error - ); - const warnings = diagnostics.filter( - d => d.severity === DiagnosticSeverity.Warning - ); - const infos = diagnostics.filter( - d => - d.severity === DiagnosticSeverity.Information || - d.severity === DiagnosticSeverity.Hint - ); - - return ( -
- {/* Badge (always visible) */} -
- {errors.length > 0 && ( - - - {errors.length} - - )} - {warnings.length > 0 && ( - - - {warnings.length} - - )} - {infos.length > 0 && ( - - - {infos.length} - - )} -
- - {/* Hover tooltip */} -
-
-
    - {diagnostics.map((d, i) => ( -
  • - - {d.severity === DiagnosticSeverity.Error && ( - - )} - {d.severity === DiagnosticSeverity.Warning && ( - - )} - {(d.severity === DiagnosticSeverity.Information || - d.severity === DiagnosticSeverity.Hint) && ( - - )} - - - {d.message} - -
  • - ))} -
-
-
-
- ); -} diff --git a/apps/ui/src/components/graph/nodes/LlmNode.tsx b/apps/ui/src/components/graph/nodes/LlmNode.tsx deleted file mode 100644 index b2a03303..00000000 --- a/apps/ui/src/components/graph/nodes/LlmNode.tsx +++ /dev/null @@ -1,83 +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 type { NodeProps } from '@xyflow/react'; -import { Sparkles, Play } from 'lucide-react'; -import type { GraphNodeData } from '~/lib/ast-to-graph'; -import { NodeHandles, LLM_SIDES } from './NodeHandles'; -import { useAppStore } from '~/store'; - -export function LlmNode({ data }: NodeProps) { - const actionNames = data.actionNames as string[] | undefined; - const hasActions = actionNames && actionNames.length > 0; - const openActionDrawer = useAppStore(state => state.openActionDrawer); - - const handleActionClick = ( - e: React.MouseEvent, - actionName: string, - index: number - ) => { - e.stopPropagation(); - openActionDrawer({ - actionDisplayName: actionName, - actionIndex: index, - topicName: data.topicName as string | undefined, - }); - }; - - return ( -
- - {/* Gradient background */} -
- {/* Header */} -
-
- -
-
-
- {data.label} -
- {data.subtitle && ( -
- {data.subtitle} -
- )} -
-
- - {/* Action pills — interactive */} - {hasActions && ( -
- {actionNames.map((name, index) => ( - - ))} -
- )} -
-
- ); -} diff --git a/apps/ui/src/components/graph/nodes/NodeHandles.tsx b/apps/ui/src/components/graph/nodes/NodeHandles.tsx deleted file mode 100644 index 08c2a7a1..00000000 --- a/apps/ui/src/components/graph/nodes/NodeHandles.tsx +++ /dev/null @@ -1,219 +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 - */ - -/** - * Shared handle component for graph nodes. - * - * Renders one handle per side with connected/unconnected styling: - * - Unconnected: tiny, semi-transparent, barely visible - * - Connected: larger, filled with accent color, white border - */ - -import { Handle, Position, type HandleType } from '@xyflow/react'; - -// --------------------------------------------------------------------------- -// Handle ID conventions -// --------------------------------------------------------------------------- - -/** Single top handle at center */ -const TOP_HANDLES = [{ id: 'top', left: '50%' }] as const; - -/** Single bottom handle at center */ -const BOTTOM_HANDLES = [{ id: 'bottom', left: '50%' }] as const; - -/** Single left handle at center */ -const LEFT_HANDLES = [{ id: 'left', top: '50%' }] as const; - -/** Single right handle at center */ -const RIGHT_HANDLES = [{ id: 'right', top: '50%' }] as const; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export type HandleSide = 'top' | 'bottom' | 'left' | 'right'; - -interface SideConfig { - type: HandleType; -} - -export interface NodeHandlesProps { - /** Which sides to render and their handle type (source/target). */ - sides: Partial>; - /** Set of handle IDs that have edges connected. */ - connectedHandles?: ReadonlySet; - /** CSS color for connected handle fill (e.g., '#3b82f6' for blue). */ - accentColor?: string; -} - -// --------------------------------------------------------------------------- -// Styling -// --------------------------------------------------------------------------- - -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]'; - -function handleClass(connected: boolean, _accentColor?: string): string { - if (!connected) return UNCONNECTED; - // When connected, the bg color is set via inline style, so just use the base class - return CONNECTED_BASE; -} - -function handleStyle( - connected: boolean, - accentColor?: string, - positionOffset?: Record -): React.CSSProperties { - return { - ...positionOffset, - ...(connected && accentColor ? { backgroundColor: accentColor } : {}), - ...(connected && !accentColor ? { backgroundColor: '#6b7280' } : {}), - }; -} - -// --------------------------------------------------------------------------- -// Component -// --------------------------------------------------------------------------- - -export function NodeHandles({ - sides, - connectedHandles, - accentColor, -}: NodeHandlesProps) { - const isConnected = (id: string) => connectedHandles?.has(id) ?? false; - - return ( - <> - {sides.top && - TOP_HANDLES.map(h => ( - - ))} - - {sides.bottom && - BOTTOM_HANDLES.map(h => ( - - ))} - - {sides.left && - LEFT_HANDLES.map(h => ( - - ))} - - {sides.right && - RIGHT_HANDLES.map(h => ( - - ))} - - ); -} - -/** - * 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. - */ -export const OVERVIEW_SIDES: Partial> = { - top: { type: 'target' }, - bottom: { type: 'source' }, - left: { type: 'target' }, - right: { type: 'source' }, -}; - -/** - * Standard handle sides for detail nodes (LR layout): - * targets on left/top, sources on right/bottom. - */ -export const DETAIL_SIDES: Partial> = { - left: { type: 'target' }, - right: { type: 'source' }, - top: { type: 'target' }, - bottom: { type: 'source' }, -}; - -/** - * Start node: only source handles (bottom + right). No targets. - */ -export const START_SIDES: Partial> = { - bottom: { type: 'source' }, - right: { type: 'source' }, -}; - -/** - * Terminal node (e.g., Transition): only target handles. No sources. - */ -export const TERMINAL_SIDES: Partial> = { - top: { type: 'target' }, - left: { type: 'target' }, -}; - -/** - * Phase node handles (TB layout): target on top, source on bottom + right. - */ -export const PHASE_SIDES: Partial> = { - top: { type: 'target' }, - bottom: { type: 'source' }, - right: { type: 'source' }, -}; - -/** - * LLM node handles: top target, bottom/right source, left source (loop-back). - */ -export const LLM_SIDES: Partial> = { - top: { type: 'target' }, - bottom: { type: 'source' }, - left: { type: 'source' }, - right: { type: 'source' }, -}; - -/** - * Build Instructions: top target, bottom source. - */ -export const BUILD_INSTRUCTIONS_SIDES: Partial> = - { - top: { type: 'target' }, - bottom: { type: 'source' }, - }; diff --git a/apps/ui/src/components/graph/nodes/PhaseNode.tsx b/apps/ui/src/components/graph/nodes/PhaseNode.tsx deleted file mode 100644 index 875ee494..00000000 --- a/apps/ui/src/components/graph/nodes/PhaseNode.tsx +++ /dev/null @@ -1,154 +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 type { NodeProps } from '@xyflow/react'; -import { PlayCircle, CheckCircle, BookOpen } from 'lucide-react'; -import type { GraphNodeData, PhaseType } from '~/lib/ast-to-graph'; -import { getNodeBorderClass } from './diagnosticBorder'; -import { DiagnosticHoverCard } from './DiagnosticHoverCard'; -import { NodeHandles, PHASE_SIDES, type HandleSide } from './NodeHandles'; - -interface PhaseConfig { - icon: typeof PlayCircle; - color: string; - bgClass: string; - iconClass: string; -} - -const PHASE_CONFIGS: Record = { - 'topic-header': { - icon: PlayCircle, - color: '#0ea5e9', - bgClass: 'bg-sky-100 dark:bg-sky-900/40', - iconClass: 'text-sky-600 dark:text-sky-400', - }, - before_reasoning: { - icon: PlayCircle, - color: '#22c55e', - bgClass: 'bg-green-100 dark:bg-green-900/40', - iconClass: 'text-green-600 dark:text-green-400', - }, - after_reasoning: { - icon: CheckCircle, - color: '#f59e0b', - bgClass: 'bg-amber-100 dark:bg-amber-900/40', - iconClass: 'text-amber-600 dark:text-amber-400', - }, - before_reasoning_iteration: { - icon: BookOpen, - color: '#6366f1', - bgClass: 'bg-indigo-100 dark:bg-indigo-900/40', - iconClass: 'text-indigo-600 dark:text-indigo-400', - }, -}; - -const DEFAULT_CONFIG: PhaseConfig = { - icon: PlayCircle, - color: '#6b7280', - bgClass: 'bg-gray-100 dark:bg-gray-800/40', - iconClass: 'text-gray-600 dark:text-gray-400', -}; - -/** Loop-back target (before_reasoning_iteration): left side is a target for the upward arc */ -const PHASE_LOOP_TARGET_SIDES: Partial< - Record -> = { - top: { type: 'target' }, - bottom: { type: 'source' }, - right: { type: 'source' }, - left: { type: 'target' }, -}; - -function getSidesForPhase( - phaseType: PhaseType | undefined -): Partial> { - if (phaseType === 'before_reasoning_iteration') - return PHASE_LOOP_TARGET_SIDES; - return PHASE_SIDES; -} - -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; - const borderClass = getNodeBorderClass(selected, data.diagnostics); - const isLabel = data.nodeType === 'phase-label'; - const sides = getSidesForPhase(phaseType); - const isEmpty = data.isEmpty === true; - - // Empty phase: dashed border, muted — shows lifecycle slot exists - if (isEmpty) { - return ( -
- -
-
- -
-
-
- {data.label} -
- {data.subtitle && ( -
- {data.subtitle} -
- )} -
-
-
- ); - } - - return ( -
- - {data.diagnostics && data.diagnostics.length > 0 && ( -
- -
- )} -
-
- -
-
-
- {data.label} -
- {data.subtitle && ( -
- {data.subtitle} -
- )} -
-
-
- ); -} diff --git a/apps/ui/src/components/graph/nodes/ReasoningGroupNode.tsx b/apps/ui/src/components/graph/nodes/ReasoningGroupNode.tsx deleted file mode 100644 index 7fdc6b8a..00000000 --- a/apps/ui/src/components/graph/nodes/ReasoningGroupNode.tsx +++ /dev/null @@ -1,158 +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 type { NodeProps } from '@xyflow/react'; -import { Handle, Position } from '@xyflow/react'; -import { ArrowDown, RotateCw } from 'lucide-react'; -import type { GraphNodeData } from '~/lib/ast-to-graph'; - -/** - * Visual container node for phase groups (uses React Flow parentId nesting). - * - * All variants have invisible React Flow handles (t-c, enter-out, exit-in, b-c) - * so spine edges can route through the group boundary. - * - * Reasoning Loop: prominent solid border, gradient bg, "iterates" badge, - * "Enter Loop" handle/badge at top, "Exit" handle/badge at bottom. - * Before/After Reasoning: lighter gray container for subtle grouping. - * Empty state: dashed border, muted, no content. - * - * Dimensions come from node.style (set by the layout engine). The component - * fills its container with `width: 100%, height: 100%`. - * - * Non-interactive — purely visual grouping. - */ -export function ReasoningGroupNode({ data }: NodeProps) { - const isLoop = data.label === 'Reasoning Loop'; - const isEmpty = data.isEmpty === true; - - const containerStyle: React.CSSProperties = { - width: '100%', - height: '100%', - }; - - /** Invisible handle style — handles are hidden, edges connect through them */ - const hiddenHandle: React.CSSProperties = { - width: 1, - height: 1, - opacity: 0, - pointerEvents: 'none', - }; - - // Shared handles for ALL group variants (spine edges route through these) - const groupHandles = ( - <> - {/* External target at top */} - - {/* Internal source at top — edge goes DOWN to first child */} - - {/* Internal target at bottom — edge arrives from last child */} - - {/* External source at bottom */} - - - ); - - // Empty before/after reasoning: dashed container with visible border - if (isEmpty && !isLoop) { - return ( -
- {groupHandles} -
- - - {data.label} - -
-
- ); - } - - return ( -
- {groupHandles} - - {/* Enter Loop badge — top center (Reasoning Loop only) */} - {isLoop && ( -
- - Enter Loop -
- )} - - {/* Exit badge — bottom center (Reasoning Loop only) */} - {isLoop && ( -
- - Exit -
- )} - - {/* Header */} -
-
- - - {data.label} - -
- {isLoop && ( - - iterates - - )} -
-
- ); -} diff --git a/apps/ui/src/components/graph/nodes/RunNode.tsx b/apps/ui/src/components/graph/nodes/RunNode.tsx deleted file mode 100644 index 4affa75e..00000000 --- a/apps/ui/src/components/graph/nodes/RunNode.tsx +++ /dev/null @@ -1,39 +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 type { NodeProps } from '@xyflow/react'; -import { Play } from 'lucide-react'; -import type { GraphNodeData } from '~/lib/ast-to-graph'; -import { getNodeBorderClass } from './diagnosticBorder'; -import { NodeHandles, DETAIL_SIDES } from './NodeHandles'; - -export function RunNode({ data, selected }: NodeProps) { - const borderClass = getNodeBorderClass(selected, data.diagnostics); - - return ( -
- -
-
- -
-
-
- {data.label} -
-
Run
-
-
-
- ); -} diff --git a/apps/ui/src/components/graph/nodes/SetNode.tsx b/apps/ui/src/components/graph/nodes/SetNode.tsx deleted file mode 100644 index 9175c784..00000000 --- a/apps/ui/src/components/graph/nodes/SetNode.tsx +++ /dev/null @@ -1,41 +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 type { NodeProps } from '@xyflow/react'; -import { Equal } from 'lucide-react'; -import type { GraphNodeData } from '~/lib/ast-to-graph'; -import { getNodeBorderClass } from './diagnosticBorder'; -import { NodeHandles, DETAIL_SIDES } from './NodeHandles'; - -export function SetNode({ data, selected }: NodeProps) { - const borderClass = getNodeBorderClass(selected, data.diagnostics); - - return ( -
- -
-
- -
-
-
- {data.label} -
-
- = {data.subtitle} -
-
-
-
- ); -} diff --git a/apps/ui/src/components/graph/nodes/StartNode.tsx b/apps/ui/src/components/graph/nodes/StartNode.tsx deleted file mode 100644 index 03ce8d9a..00000000 --- a/apps/ui/src/components/graph/nodes/StartNode.tsx +++ /dev/null @@ -1,42 +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 type { NodeProps } from '@xyflow/react'; -import { Play } from 'lucide-react'; -import type { GraphNodeData } from '~/lib/ast-to-graph'; -import { GRAPH } from '~/lib/graph-tokens'; -import { NodeHandles, START_SIDES } from './NodeHandles'; - -export function StartNode({ data, selected }: NodeProps) { - return ( -
- -
- -
- - Start - -
- ); -} diff --git a/apps/ui/src/components/graph/nodes/TemplateNode.tsx b/apps/ui/src/components/graph/nodes/TemplateNode.tsx deleted file mode 100644 index a91dd200..00000000 --- a/apps/ui/src/components/graph/nodes/TemplateNode.tsx +++ /dev/null @@ -1,39 +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 type { NodeProps } from '@xyflow/react'; -import { AlignLeft } from 'lucide-react'; -import type { GraphNodeData } from '~/lib/ast-to-graph'; -import { getNodeBorderClass } from './diagnosticBorder'; -import { NodeHandles, DETAIL_SIDES } from './NodeHandles'; - -export function TemplateNode({ data, selected }: NodeProps) { - const text = data.label ?? ''; - const borderClass = getNodeBorderClass(selected, data.diagnostics); - - return ( -
- -
-
- -
-
-
- {text} -
-
-
-
- ); -} diff --git a/apps/ui/src/components/graph/nodes/TopicNode.tsx b/apps/ui/src/components/graph/nodes/TopicNode.tsx deleted file mode 100644 index 3236eb7f..00000000 --- a/apps/ui/src/components/graph/nodes/TopicNode.tsx +++ /dev/null @@ -1,71 +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 type { NodeProps } from '@xyflow/react'; -import { ChevronRight } from 'lucide-react'; -import type { GraphNodeData } from '~/lib/ast-to-graph'; -import { getBlockTypeConfig } from '~/lib/block-type-config'; -import { GRAPH } from '~/lib/graph-tokens'; -import { getNodeBorderClass } from './diagnosticBorder'; -import { DiagnosticHoverCard } from './DiagnosticHoverCard'; -import { NodeHandles, OVERVIEW_SIDES } from './NodeHandles'; - -export function TopicNode({ data, selected }: NodeProps) { - const isStartAgent = !!data.isStartAgent; - const config = getBlockTypeConfig(data.blockType, { - isStartAgent, - iconSize: 20, - }); - const diagnosticClass = getNodeBorderClass(selected, data.diagnostics); - const hasDiagnosticBorder = diagnosticClass !== ''; - - const accentColor = isStartAgent ? GRAPH.intelligence.accent : '#38bdf8'; // sky-400 — topics are inviting destinations - - // Start Agent: indigo-tinted — it routes conversations (intelligence color) - // 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'; - - return ( -
- - {data.diagnostics && data.diagnostics.length > 0 && ( -
- -
- )} -
-
- {config.icon} -
-
-
- {data.label} -
-
- {data.subtitle ?? (isStartAgent ? 'Start Agent' : 'Topic')} -
-
- -
-
- ); -} diff --git a/apps/ui/src/components/graph/nodes/TransitionNode.tsx b/apps/ui/src/components/graph/nodes/TransitionNode.tsx deleted file mode 100644 index 93da26f8..00000000 --- a/apps/ui/src/components/graph/nodes/TransitionNode.tsx +++ /dev/null @@ -1,44 +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 type { NodeProps } from '@xyflow/react'; -import { ArrowRightCircle } from 'lucide-react'; -import type { GraphNodeData } from '~/lib/ast-to-graph'; -import { getNodeBorderClass } from './diagnosticBorder'; -import { NodeHandles, TERMINAL_SIDES } from './NodeHandles'; - -export function TransitionNode({ data, selected }: NodeProps) { - const borderClass = getNodeBorderClass(selected, data.diagnostics); - - return ( -
- -
-
- -
-
-
- {data.label} -
-
- Transition -
-
-
-
- ); -} diff --git a/apps/ui/src/components/graph/nodes/diagnosticBorder.ts b/apps/ui/src/components/graph/nodes/diagnosticBorder.ts deleted file mode 100644 index db1a747c..00000000 --- a/apps/ui/src/components/graph/nodes/diagnosticBorder.ts +++ /dev/null @@ -1,36 +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 { DiagnosticSeverity, type Diagnostic } from '@agentscript/types'; - -/** - * Compute the border/ring classes for a graph node based on - * its selection state and diagnostics. - */ -export function getNodeBorderClass( - selected: boolean | undefined, - diagnostics: Diagnostic[] | undefined -): string { - if (selected) { - return 'border-blue-400 ring-2 ring-blue-200 shadow-lg shadow-blue-500/10 dark:ring-blue-800 dark:shadow-blue-500/8'; - } - if (diagnostics && diagnostics.length > 0) { - const hasError = diagnostics.some( - d => d.severity === DiagnosticSeverity.Error - ); - if (hasError) { - return 'border-red-400 ring-2 ring-red-400/20 dark:border-red-500 dark:ring-red-500/20'; - } - const hasWarning = diagnostics.some( - d => d.severity === DiagnosticSeverity.Warning - ); - if (hasWarning) { - return 'border-amber-400 ring-2 ring-amber-400/20 dark:border-amber-500 dark:ring-amber-500/20'; - } - } - return ''; -} diff --git a/apps/ui/src/components/graph/nodes/index.ts b/apps/ui/src/components/graph/nodes/index.ts deleted file mode 100644 index 3ebe0458..00000000 --- a/apps/ui/src/components/graph/nodes/index.ts +++ /dev/null @@ -1,54 +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 { StartNode } from './StartNode'; -import { TopicNode } from './TopicNode'; -import { ActionNode } from './ActionNode'; -import { CompoundTopicNode } from './CompoundTopicNode'; -import { ConditionalNode } from './ConditionalNode'; -import { TransitionNode } from './TransitionNode'; -import { RunNode } from './RunNode'; -import { SetNode } from './SetNode'; -import { TemplateNode } from './TemplateNode'; -import { PhaseNode } from './PhaseNode'; -import { LlmNode } from './LlmNode'; -import { ReasoningGroupNode } from './ReasoningGroupNode'; -import { BuildInstructionsNode } from './BuildInstructionsNode'; -export { - StartNode, - TopicNode, - ActionNode, - CompoundTopicNode, - ConditionalNode, - TransitionNode, - RunNode, - SetNode, - TemplateNode, - PhaseNode, - LlmNode, - ReasoningGroupNode, - BuildInstructionsNode, -}; - -/** Node type registry for React Flow */ -export const graphNodeTypes = { - start: StartNode, - 'start-agent': TopicNode, - topic: TopicNode, - action: ActionNode, - 'compound-topic': CompoundTopicNode, - conditional: ConditionalNode, - transition: TransitionNode, - run: RunNode, - set: SetNode, - template: TemplateNode, - phase: PhaseNode, - 'phase-label': PhaseNode, - llm: LlmNode, - 'reasoning-group': ReasoningGroupNode, - 'build-instructions': BuildInstructionsNode, -} as const; diff --git a/apps/ui/src/index.css b/apps/ui/src/index.css index 28fbacd6..6e317d28 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-to-graph.ts b/apps/ui/src/lib/ast-to-graph.ts deleted file mode 100644 index b5e6349b..00000000 --- a/apps/ui/src/lib/ast-to-graph.ts +++ /dev/null @@ -1,1407 +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 - */ - -/** - * AST to Graph Data Transformer - * - * Converts AgentScript AST into React Flow-compatible node/edge arrays - * for the Graph view. Two modes: - * - Overview: topics as nodes, transitions as edges - * - Topic Detail: compound sections, actions, conditionals, transitions - */ - -import type { Node, Edge } from '@xyflow/react'; -import { - collectDiagnostics, - decomposeAtMemberExpression, - isNamedMap, - NamedMap, - type Diagnostic, - type Statement, -} from '@agentscript/language'; -import type { AgentScriptAST } from '~/lib/parser'; -import { findTopicBlock } from '~/lib/ast-utils'; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export type GraphNodeType = - | 'start' - | 'start-agent' - | 'topic' - | 'action' - | 'compound-topic' - | 'conditional' - | 'transition' - | 'run' - | 'set' - | 'template' - | 'phase' - | 'phase-label' - | 'llm' - | 'build-instructions' - | 'reasoning-group'; - -export type PhaseType = - | 'topic-header' - | 'before_reasoning' - | 'after_reasoning' - | 'before_reasoning_iteration'; - -/** Well-known group container IDs for post-layout positioning. */ -export const GROUP_IDS = { - beforeReasoning: 'group-before-reasoning', - reasoningLoop: 'group-reasoning-loop', - afterReasoning: 'group-after-reasoning', -} as const; - -export interface GraphNodeData extends Record { - nodeType: GraphNodeType; - label: string; - subtitle?: string; - blockType: string; - isStartAgent?: boolean; - topicName?: string; - conditionText?: string; - /** Short human-readable label derived from the condition (for compact display). */ - conditionLabel?: string; - transitionTarget?: string; - sections?: string[]; - actionNames?: string[]; - /** Raw action map keys (parallel to actionNames) for AST lookup. */ - actionKeys?: string[]; - diagnostics?: Diagnostic[]; - /** Phase type for phase/phase-label nodes */ - phaseType?: PhaseType; - /** Which group container this node belongs to (for post-layout grouping). */ - groupId?: string; - /** True for nodes on the main execution pipeline (spine). */ - isSpine?: boolean; - /** Ordering index on the spine (0 = first). Used by deterministic layout. */ - spineIndex?: number; - /** True when a container/phase exists but has no child statements. */ - isEmpty?: boolean; - /** Set of handle IDs that have edges connected (populated after layout). */ - connectedHandles?: ReadonlySet; - /** Horizontal offset from container left edge to spine center (for group handle positioning). */ - spineOffsetX?: number; -} - -/** Data attached to conditional edges for the drawer. */ -export interface ConditionalEdgeData extends Record { - conditionText: string; - sourceTopicName: string; - conditionalKey: string; -} - -/** Data for the action detail drawer. */ -export interface ActionDrawerData { - actionDisplayName: string; - actionIndex: number; - topicName?: string; -} - -/** Data for the node detail drawer (any clickable graph node). */ -export interface NodeDrawerData { - nodeId: string; - nodeType: GraphNodeType; - label: string; - subtitle?: string; - topicName?: string; - conditionText?: string; - conditionLabel?: string; - transitionTarget?: string; - phaseType?: PhaseType; - actionNames?: string[]; - actionKeys?: string[]; - isEmpty?: boolean; -} - -/** Discriminated union for graph drawer content types. */ -export type GraphDrawerPayload = - | { type: 'conditional'; data: ConditionalEdgeData } - | { type: 'action'; data: ActionDrawerData } - | { type: 'node'; data: NodeDrawerData }; - -export type GraphNode = Node; -export type GraphEdge = Edge; - -interface TransitionInfo { - targetTopicName: string; - conditionText?: string; - /** Which branch of a conditional this transition is in */ - branch?: 'if' | 'else'; - /** Groups if+else branches of the same conditional (uses the condition text) */ - conditionalKey?: string; -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** - * Derive a short human-readable label from a condition expression. - * E.g. `@variables.checked_loyalty_tier == False` → `Checked Loyalty Tier?` - */ -function abbreviateCondition(condText: string): string { - // Try to extract a variable name from @variables.xxx or @xxx.yyy patterns - const varMatch = condText.match(/@\w+\.(\w+)/); - if (varMatch) { - const varName = varMatch[1]; - // Convert snake_case to Title Case and append "?" - const label = varName - .split('_') - .map(w => w.charAt(0).toUpperCase() + w.slice(1)) - .join(' '); - return `${label}?`; - } - // Fall back to truncated text - if (condText.length > 18) { - return `${condText.slice(0, 18)}...`; - } - return condText; -} - -function toDisplayLabel(name: string): string { - return name - .split('_') - .map(word => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' '); -} - -/** Get the label from a topic block, falling back to formatted name. */ -function getTopicLabel(block: Record, name: string): string { - const label = block.label as { value?: string } | undefined; - return label?.value ?? toDisplayLabel(name); -} - -/** - * Resolve a ToClause target expression to a topic name. - * Handles `@topic.name` MemberExpression patterns. - */ -function resolveTransitionTarget(expr: unknown): string | null { - const decomposed = decomposeAtMemberExpression(expr); - if (decomposed && decomposed.namespace === 'topic') { - return decomposed.property; - } - return null; -} - -/** - * Recursively walk statement arrays to extract all transition targets. - * Handles TransitionStatement (with ToClause children) and IfStatement branches. - */ -function extractTransitions( - statements: Statement[], - parentCondition?: string, - parentBranch?: 'if' | 'else', - parentConditionalKey?: string -): TransitionInfo[] { - const transitions: TransitionInfo[] = []; - - for (const stmt of statements) { - if (stmt.__kind === 'TransitionStatement') { - const transition = stmt as { clauses: Statement[] }; - for (const clause of transition.clauses) { - if (clause.__kind === 'ToClause') { - const toClause = clause as { target: unknown }; - const target = resolveTransitionTarget(toClause.target); - if (target) { - transitions.push({ - targetTopicName: target, - conditionText: parentCondition, - branch: parentBranch, - conditionalKey: parentConditionalKey, - }); - } - } - } - } else if (stmt.__kind === 'IfStatement') { - const ifStmt = stmt as { - condition?: { __emit?(ctx: { indent: number }): string }; - body: Statement[]; - orelse: Statement[]; - }; - const condText = ifStmt.condition?.__emit?.({ indent: 0 }) ?? ''; - transitions.push( - ...extractTransitions(ifStmt.body, condText, 'if', condText) - ); - - if (ifStmt.orelse?.length > 0) { - if ( - ifStmt.orelse.length === 1 && - ifStmt.orelse[0].__kind === 'IfStatement' - ) { - // elif chain — recurse (each elif gets its own conditionalKey) - transitions.push(...extractTransitions(ifStmt.orelse)); - } else { - // else branch — same conditionalKey as the if - transitions.push( - ...extractTransitions(ifStmt.orelse, condText, 'else', condText) - ); - } - } - } - } - - return transitions; -} - -/** Get statements from a ProcedureValue field. */ -function getProcedureStatements(procedure: unknown): Statement[] { - if (!procedure || typeof procedure !== 'object') return []; - const proc = procedure as { statements?: Statement[] }; - return proc.statements ?? []; -} - -/** - * Extract all transitions from a topic block. - * Searches three locations: - * 1. after_reasoning.statements[] — TransitionStatement / IfStatement with ToClause - * 2. reasoning.instructions.statements[] — TransitionStatement / IfStatement with ToClause - * 3. reasoning.actions (Map) — each has statements[] with ToClause - * These are `@utils.transition` reasoning actions (e.g., go_to_identity, go_to_order) - */ -function extractAllTopicTransitions( - block: Record -): TransitionInfo[] { - const transitions: TransitionInfo[] = []; - const seen = new Set(); - - const addUnique = (infos: TransitionInfo[]) => { - for (const info of infos) { - const key = `${info.targetTopicName}:${info.conditionText ?? ''}:${info.branch ?? ''}:${info.conditionalKey ?? ''}`; - if (!seen.has(key)) { - seen.add(key); - transitions.push(info); - } - } - }; - - // 1. after_reasoning - addUnique(extractTransitions(getProcedureStatements(block.after_reasoning))); - - // 2. reasoning.instructions - const reasoning = block.reasoning as Record | undefined; - if (reasoning) { - addUnique( - extractTransitions(getProcedureStatements(reasoning.instructions)) - ); - - // 3. reasoning.actions (Map of ReasoningActionBlock) - const reasoningActions = reasoning.actions as - | NamedMap> - | undefined; - if (isNamedMap(reasoningActions)) { - for (const [, ab] of reasoningActions) { - const stmts = ab.statements as Statement[] | undefined; - if (stmts) { - addUnique(extractTransitionsFromReasoningAction(stmts)); - } - } - } - } - - // 3. before_reasoning (can also contain transitions) - addUnique(extractTransitions(getProcedureStatements(block.before_reasoning))); - - return transitions; -} - -/** - * Extract transitions from a ReasoningActionBlock's statements. - * These contain ToClause (direct target) and AvailableWhen (conditions). - */ -function extractTransitionsFromReasoningAction( - statements: Statement[] -): TransitionInfo[] { - const transitions: TransitionInfo[] = []; - for (const stmt of statements) { - if (stmt.__kind === 'ToClause') { - const toClause = stmt 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[] }; - for (const clause of transition.clauses) { - if (clause.__kind === 'ToClause') { - const toClause = clause as { target: unknown }; - const target = resolveTransitionTarget(toClause.target); - if (target) { - transitions.push({ targetTopicName: target }); - } - } - } - } - } - return transitions; -} - -/** Get the names of all topics (both start_agent and topic). */ -function getAllTopicNames(ast: AgentScriptAST): Set { - const names = new Set(); - const startAgent = ast.start_agent as - | NamedMap> - | undefined; - const topics = ast.topic as NamedMap> | undefined; - if (isNamedMap(startAgent)) { - for (const name of startAgent.keys()) names.add(name as string); - } - if (isNamedMap(topics)) { - for (const name of topics.keys()) names.add(name as string); - } - return names; -} - -// --------------------------------------------------------------------------- -// Overview Graph -// --------------------------------------------------------------------------- - -/** - * Convert AST to an overview graph: Start → Start Agents → Topics - * with transition edges between topics. Conditional transitions use - * a dedicated edge type with the condition text as a label. - */ -export function astToOverviewGraph(ast: AgentScriptAST): { - nodes: GraphNode[]; - edges: GraphEdge[]; -} { - const nodes: GraphNode[] = []; - const edges: GraphEdge[] = []; - const validTopics = getAllTopicNames(ast); - - // Start node - nodes.push({ - id: 'start', - type: 'start', - position: { x: 0, y: 0 }, - data: { - nodeType: 'start', - label: 'Start', - blockType: 'start', - }, - }); - - /** - * Process transitions from a source topic node. - * Unconditional transitions → direct edges. - * Conditional transitions → conditional edges with condition label. - */ - function addTransitionEdges( - sourceNodeId: string, - block: Record - ) { - try { - const transitions = extractAllTopicTransitions(block); - const unconditional: TransitionInfo[] = []; - // Group conditional transitions by conditionalKey - const conditionalGroups = new Map< - string, - { ifTargets: string[]; elseTargets: string[]; condText: string } - >(); - - for (const t of transitions) { - if (!validTopics.has(t.targetTopicName)) continue; - if (t.branch && t.conditionalKey) { - let group = conditionalGroups.get(t.conditionalKey); - if (!group) { - group = { - ifTargets: [], - elseTargets: [], - condText: t.conditionalKey, - }; - conditionalGroups.set(t.conditionalKey, group); - } - const targetId = findTopicNodeId(t.targetTopicName, ast); - if (t.branch === 'if') { - group.ifTargets.push(targetId); - } else { - group.elseTargets.push(targetId); - } - } else { - unconditional.push(t); - } - } - - // Direct edges for unconditional transitions - for (const t of unconditional) { - const targetId = findTopicNodeId(t.targetTopicName, ast); - edges.push({ - id: `e-${sourceNodeId}-${targetId}`, - source: sourceNodeId, - target: targetId, - type: 'smoothstep', - }); - } - - // Conditional edges with gate-icon labels - // Derive topic name from node ID (format: "start_agent-name" or "topic-name") - const sourceTopicName = sourceNodeId.replace(/^(start_agent|topic)-/, ''); - - for (const [, group] of conditionalGroups) { - for (const targetId of group.ifTargets) { - edges.push({ - id: `e-${sourceNodeId}-if-${targetId}`, - source: sourceNodeId, - target: targetId, - type: 'conditional', - label: `If: ${group.condText}`, - markerEnd: { type: 'arrowclosed' as const, color: '#9ca3af' }, - data: { - conditionText: group.condText, - sourceTopicName, - conditionalKey: group.condText, - }, - }); - } - - for (const targetId of group.elseTargets) { - edges.push({ - id: `e-${sourceNodeId}-else-${targetId}`, - source: sourceNodeId, - target: targetId, - type: 'conditional', - label: 'Else', - markerEnd: { type: 'arrowclosed' as const, color: '#9ca3af' }, - data: { - conditionText: group.condText, - sourceTopicName, - conditionalKey: group.condText, - }, - }); - } - } - } catch { - // Skip transition extraction if AST is malformed - } - } - - // Start Agent nodes - const startAgent = ast.start_agent as - | NamedMap> - | undefined; - if (isNamedMap(startAgent)) { - for (const [name, block] of startAgent) { - const nodeId = `start_agent-${name}`; - const blockDiagnostics = collectDiagnostics(block); - nodes.push({ - id: nodeId, - type: 'start-agent', - position: { x: 0, y: 0 }, - data: { - nodeType: 'start-agent', - label: getTopicLabel(block, name), - subtitle: 'Start Agent', - blockType: 'start_agent', - isStartAgent: true, - topicName: name, - diagnostics: blockDiagnostics, - }, - }); - - // Edge from Start to this start_agent - edges.push({ - id: `e-start-${nodeId}`, - source: 'start', - target: nodeId, - type: 'smoothstep', - }); - - addTransitionEdges(nodeId, block); - } - } - - // Topic nodes - const topics = ast.topic as NamedMap> | undefined; - if (isNamedMap(topics)) { - for (const [name, block] of topics) { - const nodeId = `topic-${name}`; - const blockDiagnostics = collectDiagnostics(block); - nodes.push({ - id: nodeId, - type: 'topic', - position: { x: 0, y: 0 }, - data: { - nodeType: 'topic', - label: getTopicLabel(block, name), - subtitle: 'Topic', - blockType: 'topic', - topicName: name, - diagnostics: blockDiagnostics, - }, - }); - - addTransitionEdges(nodeId, block); - } - } - - return { nodes, edges }; -} - -/** Resolve a topic name to its node ID (checking start_agent first, then topic). */ -function findTopicNodeId(name: string, ast: AgentScriptAST): string { - const startAgent = ast.start_agent as - | NamedMap> - | undefined; - if (isNamedMap(startAgent) && startAgent.has(name)) { - return `start_agent-${name}`; - } - return `topic-${name}`; -} - -// --------------------------------------------------------------------------- -// Topic Detail Graph — Execution Pipeline -// --------------------------------------------------------------------------- - -/** - * Convert a single topic into a detail graph showing its execution pipeline. - * - * Structure: - * [Topic Header] - * ↓ - * ┌─ BEFORE REASONING ─────────────┐ (container, only if has statements) - * │ [child run/set/template nodes] │ - * └────────────┬────────────────────┘ - * ↓ - * ┌─ REASONING LOOP ───────────────────────────────┐ - * │ [Before Reasoning Iteration] │ - * │ ↓ ↘ [template/conditional] │ - * │ [Agent Reasoning] │ - * │ ↓ │ - * │ [Tool Execution] │ - * │ ↘ [Action nodes] │ - * │ ↓ │ - * │ [After All Tool Calls] │ - * │ ↘ [Transition nodes] │ - * │ ↓ ↑ loop back │ - * └──────┬────────────┴─────────────────────────────┘ - * ↓ exit - * ┌─ AFTER REASONING ──────────────┐ (container, only if has statements) - * │ [child set/transition nodes] │ - * └────────────────────────────────┘ - * - * Nodes carry a `groupId` in their data so Graph.tsx can compute container - * bounding boxes after ELK layout and position the group background nodes. - */ -export function astToTopicDetailGraph( - ast: AgentScriptAST, - topicName: string -): { nodes: GraphNode[]; edges: GraphEdge[] } { - const nodes: GraphNode[] = []; - const edges: GraphEdge[] = []; - - const block = findTopicBlock(ast, topicName); - if (!block) return { nodes, edges }; - - const topicBlock = block as Record; - const topicDiagnostics = collectDiagnostics(topicBlock); - const reasoning = topicBlock.reasoning as Record | undefined; - - let condIdx = 0; - let transIdx = 0; - let runIdx = 0; - let setIdx = 0; - let tplIdx = 0; - const counters: IdCounters = { - getCondIdx: () => condIdx++, - getTransIdx: () => transIdx++, - getRunIdx: () => runIdx++, - getSetIdx: () => setIdx++, - getTplIdx: () => tplIdx++, - }; - - // Track the last pipeline node for sequential connections - let lastPipelineId: string | undefined; - let lastPipelineHandle: string | undefined; - let spineCounter = 0; - - const connectPipeline = (targetId: string) => { - // Tag the node as a spine node - const node = nodes.find(n => n.id === targetId); - if (node) { - node.data = { ...node.data, isSpine: true, spineIndex: spineCounter++ }; - } - - if (lastPipelineId) { - edges.push({ - id: `e-pipe-${lastPipelineId}-${targetId}`, - source: lastPipelineId, - sourceHandle: lastPipelineHandle, - target: targetId, - type: 'smoothstep', - data: { edgeRole: 'spine' }, - }); - } - lastPipelineId = targetId; - lastPipelineHandle = undefined; - }; - - // ----------------------------------------------------------------------- - // 0. Topic Header — always present - // ----------------------------------------------------------------------- - const headerId = 'topic-header'; - nodes.push({ - id: headerId, - type: 'phase', - position: { x: 0, y: 0 }, - data: { - nodeType: 'phase', - label: getTopicLabel(topicBlock, topicName), - subtitle: topicName, - blockType: 'topic', - phaseType: 'topic-header', - topicName, - diagnostics: topicDiagnostics, - }, - }); - connectPipeline(headerId); - - // ----------------------------------------------------------------------- - // 1. Before Reasoning phase (always shown; empty if no statements) - // ----------------------------------------------------------------------- - const beforeStatements = getProcedureStatements(topicBlock.before_reasoning); - const beforeEmpty = beforeStatements.length === 0; - - // Group container (visual — positioned post-layout) - nodes.push({ - id: GROUP_IDS.beforeReasoning, - type: 'reasoning-group', - position: { x: 0, y: 0 }, - data: { - nodeType: 'reasoning-group', - label: 'Before Reasoning', - blockType: 'topic', - isEmpty: beforeEmpty, - }, - }); - - // Phase header inside the container - const beforeId = 'before-reasoning'; - nodes.push({ - id: beforeId, - type: 'phase', - position: { x: 0, y: 0 }, - data: { - nodeType: 'phase', - label: 'Before Reasoning', - subtitle: beforeEmpty ? 'no hooks configured' : 'every turn', - blockType: 'topic', - phaseType: 'before_reasoning', - groupId: GROUP_IDS.beforeReasoning, - topicName, - isEmpty: beforeEmpty, - }, - }); - // Route spine through the before-reasoning group handles: - // previous → group (t-c) → group (enter-out) → before-reasoning → group (exit-in) → group (b-c) → next - edges.push({ - id: `e-pipe-${lastPipelineId}-${GROUP_IDS.beforeReasoning}`, - source: lastPipelineId!, - sourceHandle: lastPipelineHandle, - target: GROUP_IDS.beforeReasoning, - targetHandle: 'top', - type: 'smoothstep', - data: { edgeRole: 'spine' }, - }); - edges.push({ - id: `e-pipe-${GROUP_IDS.beforeReasoning}-${beforeId}`, - source: GROUP_IDS.beforeReasoning, - sourceHandle: 'enter-out', - target: beforeId, - type: 'smoothstep', - data: { edgeRole: 'spine' }, - }); - // Tag as spine manually (can't use connectPipeline — it would create a direct edge) - const beforeNode = nodes.find(n => n.id === beforeId); - if (beforeNode) { - beforeNode.data = { - ...beforeNode.data, - isSpine: true, - spineIndex: spineCounter++, - }; - } - lastPipelineId = beforeId; - lastPipelineHandle = undefined; - - if (!beforeEmpty) { - buildDetailNodes( - beforeStatements, - beforeId, - undefined, - nodes, - edges, - counters, - GROUP_IDS.beforeReasoning - ); - } - - // Exit the before-reasoning group - edges.push({ - id: `e-pipe-${lastPipelineId}-${GROUP_IDS.beforeReasoning}-exit`, - source: lastPipelineId!, - sourceHandle: lastPipelineHandle, - target: GROUP_IDS.beforeReasoning, - targetHandle: 'exit-in', - type: 'smoothstep', - data: { edgeRole: 'spine' }, - }); - lastPipelineId = GROUP_IDS.beforeReasoning; - lastPipelineHandle = 'bottom'; - - // ----------------------------------------------------------------------- - // 2. Reasoning Loop (always shown if reasoning block exists) - // ----------------------------------------------------------------------- - if (reasoning) { - // Group container (visual — positioned post-layout) - nodes.push({ - id: GROUP_IDS.reasoningLoop, - type: 'reasoning-group', - position: { x: 0, y: 0 }, - data: { - nodeType: 'reasoning-group', - label: 'Reasoning Loop', - blockType: 'topic', - }, - }); - - // 2a. Enter the loop: spine goes through group handles - // before-reasoning → group (top) → group (enter-out) → before-reasoning-iteration - edges.push({ - id: `e-pipe-${lastPipelineId}-${GROUP_IDS.reasoningLoop}`, - source: lastPipelineId!, - sourceHandle: lastPipelineHandle, - target: GROUP_IDS.reasoningLoop, - targetHandle: 'top', - type: 'smoothstep', - data: { edgeRole: 'spine' }, - }); - - const iterationId = 'before-reasoning-iteration'; - nodes.push({ - id: iterationId, - type: 'phase-label', - position: { x: 0, y: 0 }, - data: { - nodeType: 'phase-label', - label: 'Before Reasoning Iteration', - subtitle: 'every iteration', - blockType: 'topic', - phaseType: 'before_reasoning_iteration', - groupId: GROUP_IDS.reasoningLoop, - }, - }); - - // Edge from Enter Loop handle to first child inside the group - edges.push({ - id: `e-pipe-${GROUP_IDS.reasoningLoop}-${iterationId}`, - source: GROUP_IDS.reasoningLoop, - sourceHandle: 'enter-out', - target: iterationId, - type: 'smoothstep', - data: { edgeRole: 'spine' }, - }); - // Tag iteration as spine and continue pipeline from here - const iterNode = nodes.find(n => n.id === iterationId); - if (iterNode) { - iterNode.data = { - ...iterNode.data, - isSpine: true, - spineIndex: spineCounter++, - }; - } - lastPipelineId = iterationId; - lastPipelineHandle = undefined; - - // 2b. Build child nodes from reasoning.instructions (templates, conditionals) - const instrStatements = getProcedureStatements(reasoning.instructions); - const nodeCountBeforeInstr = nodes.length; - - const leafNodeIds = buildDetailNodes( - instrStatements, - iterationId, - undefined, - nodes, - edges, - counters, - GROUP_IDS.reasoningLoop - ); - - // Move transition nodes outside the loop (transitions are exits) - for (let i = nodeCountBeforeInstr; i < nodes.length; i++) { - if (nodes[i].data.nodeType === 'transition') { - nodes[i] = { - ...nodes[i], - data: { ...nodes[i].data, groupId: undefined }, - }; - } - } - - // 2c. Build Instructions node — collects template outputs - const buildInstrId = 'build-instructions'; - nodes.push({ - id: buildInstrId, - type: 'build-instructions', - position: { x: 0, y: 0 }, - data: { - nodeType: 'build-instructions', - label: 'Build Instructions', - blockType: 'topic', - groupId: GROUP_IDS.reasoningLoop, - }, - }); - // Tag as spine for layout positioning but no edge from iteration - const biNode = nodes.find(n => n.id === buildInstrId); - if (biNode) { - biNode.data = { - ...biNode.data, - isSpine: true, - spineIndex: spineCounter++, - }; - } - lastPipelineId = buildInstrId; - lastPipelineHandle = undefined; - - // Converge edges: each leaf instruction node → build-instructions (top handles) - for (const leafId of leafNodeIds) { - edges.push({ - id: `e-converge-${leafId}-${buildInstrId}`, - source: leafId, - sourceHandle: 'bottom', - target: buildInstrId, - type: 'smoothstep', - data: { edgeRole: 'converge' }, - }); - } - - // 2d. Agent Reasoning (LLM node) - const reasoningActions = reasoning.actions as - | NamedMap> - | undefined; - const actionDisplayNames: string[] = []; - const actionKeyNames: string[] = []; - if (isNamedMap(reasoningActions)) { - for (const [actionName, actionBlock] of reasoningActions) { - const actionLabel = - (actionBlock.label as { value?: string })?.value ?? - toDisplayLabel(actionName); - actionDisplayNames.push(actionLabel); - actionKeyNames.push(actionName); - } - } - - const llmId = 'reasoning-llm'; - nodes.push({ - id: llmId, - type: 'llm', - position: { x: 0, y: 0 }, - data: { - nodeType: 'llm', - label: 'Agent Reasoning', - subtitle: 'selects tools', - blockType: 'topic', - groupId: GROUP_IDS.reasoningLoop, - actionNames: actionDisplayNames, - actionKeys: actionKeyNames, - topicName, - }, - }); - connectPipeline(llmId); - - // Transition nodes from reasoning actions — OUTSIDE the loop (no groupId) - if (isNamedMap(reasoningActions)) { - for (const [, actionBlock] of reasoningActions) { - const actionStmts = actionBlock.statements as Statement[] | undefined; - if (actionStmts) { - buildDetailNodesFromReasoningAction( - actionStmts, - llmId, - nodes, - edges, - counters, - undefined // no groupId — transitions live outside the loop - ); - } - } - } - - // 2e. Loop-back edge from agent reasoning to before-reasoning-iteration - edges.push({ - id: 'e-loop-back', - source: llmId, - sourceHandle: 'left', - target: iterationId, - targetHandle: 'left', - type: 'loop-back', - }); - - // 2f. Exit the loop: spine goes through group handles - // reasoning-llm → group (exit-in) → group (b-c) → after-reasoning - edges.push({ - id: `e-pipe-${lastPipelineId}-${GROUP_IDS.reasoningLoop}-exit`, - source: lastPipelineId!, - sourceHandle: lastPipelineHandle, - target: GROUP_IDS.reasoningLoop, - targetHandle: 'exit-in', - type: 'smoothstep', - data: { edgeRole: 'spine' }, - }); - lastPipelineId = GROUP_IDS.reasoningLoop; - lastPipelineHandle = 'bottom'; - } - - // ----------------------------------------------------------------------- - // 3. After Reasoning phase (always shown; empty if no statements) - // ----------------------------------------------------------------------- - const afterStatements = getProcedureStatements(topicBlock.after_reasoning); - const afterEmpty = afterStatements.length === 0; - - // Group container (visual — positioned post-layout) - nodes.push({ - id: GROUP_IDS.afterReasoning, - type: 'reasoning-group', - position: { x: 0, y: 0 }, - data: { - nodeType: 'reasoning-group', - label: 'After Reasoning', - blockType: 'topic', - isEmpty: afterEmpty, - }, - }); - - // Phase header inside the container - const afterId = 'after-reasoning'; - nodes.push({ - id: afterId, - type: 'phase', - position: { x: 0, y: 0 }, - data: { - nodeType: 'phase', - label: 'After Reasoning', - subtitle: afterEmpty ? 'no hooks configured' : 'every turn', - blockType: 'topic', - phaseType: 'after_reasoning', - groupId: GROUP_IDS.afterReasoning, - }, - }); - // Route spine through the after-reasoning group handles: - // previous → group (top) → group (enter-out) → after-reasoning → group (exit-in) → group (bottom) → next - edges.push({ - id: `e-pipe-${lastPipelineId}-${GROUP_IDS.afterReasoning}`, - source: lastPipelineId!, - sourceHandle: lastPipelineHandle, - target: GROUP_IDS.afterReasoning, - targetHandle: 'top', - type: 'smoothstep', - data: { edgeRole: 'spine' }, - }); - edges.push({ - id: `e-pipe-${GROUP_IDS.afterReasoning}-${afterId}`, - source: GROUP_IDS.afterReasoning, - sourceHandle: 'enter-out', - target: afterId, - type: 'smoothstep', - data: { edgeRole: 'spine' }, - }); - // Tag as spine manually - const afterNode = nodes.find(n => n.id === afterId); - if (afterNode) { - afterNode.data = { - ...afterNode.data, - isSpine: true, - spineIndex: spineCounter++, - }; - } - lastPipelineId = afterId; - lastPipelineHandle = undefined; - - if (!afterEmpty) { - buildDetailNodes( - afterStatements, - afterId, - undefined, - nodes, - edges, - counters, - GROUP_IDS.afterReasoning - ); - } - - // Exit the after-reasoning group - edges.push({ - id: `e-pipe-${lastPipelineId}-${GROUP_IDS.afterReasoning}-exit`, - source: lastPipelineId!, - sourceHandle: lastPipelineHandle, - target: GROUP_IDS.afterReasoning, - targetHandle: 'exit-in', - type: 'smoothstep', - data: { edgeRole: 'spine' }, - }); - lastPipelineId = GROUP_IDS.afterReasoning; - lastPipelineHandle = 'bottom'; - - // ----------------------------------------------------------------------- - // Post-process: set React Flow parentId for group nesting - // ----------------------------------------------------------------------- - for (const node of nodes) { - if (node.data.groupId && node.data.nodeType !== 'reasoning-group') { - node.parentId = node.data.groupId as string; - } - } - - // React Flow requires parent nodes before their children in the array. - // Stable sort: group nodes first, then everything else in original order. - nodes.sort((a, b) => { - const aIsGroup = a.data.nodeType === 'reasoning-group' ? 0 : 1; - const bIsGroup = b.data.nodeType === 'reasoning-group' ? 0 : 1; - return aIsGroup - bIsGroup; - }); - - return { nodes, edges }; -} - -interface IdCounters { - getCondIdx: () => number; - getTransIdx: () => number; - getRunIdx: () => number; - getSetIdx: () => number; - getTplIdx: () => number; -} - -/** - * Recursively build detail graph nodes from a statement list. - * Chains sequential leaf nodes (Run, Set, Template) so they form a pipeline. - * Branching nodes (If, Transition) fan out from the current source - * without advancing the chain. - * - * Returns the IDs of "leaf" nodes — terminal endpoints of all paths - * through the instruction tree. Used for converge edges to Build Instructions. - * Transition nodes are excluded (they exit the loop). - */ -function buildDetailNodes( - statements: Statement[], - sourceId: string, - sourceHandle: string | undefined, - nodes: GraphNode[], - edges: GraphEdge[], - counters: IdCounters, - groupId?: string -): string[] { - // Track current source for sequential chaining of leaf nodes (Run, Set) - let curSource = sourceId; - let curHandle: string | undefined = sourceHandle; - const leafNodeIds: string[] = []; - - for (const stmt of statements) { - if (stmt.__kind === 'TransitionStatement') { - const transition = stmt as { clauses: Statement[] }; - for (const clause of transition.clauses) { - if (clause.__kind === 'ToClause') { - const toClause = clause as { target: unknown }; - const target = resolveTransitionTarget(toClause.target); - if (target) { - const transId = `transition-${counters.getTransIdx()}`; - nodes.push({ - id: transId, - type: 'transition', - position: { x: 0, y: 0 }, - data: { - nodeType: 'transition', - label: toDisplayLabel(target), - subtitle: 'Transition', - blockType: 'topic', - transitionTarget: target, - groupId, - }, - }); - edges.push({ - id: `e-${curSource}-${transId}`, - source: curSource, - sourceHandle: curHandle, - target: transId, - type: 'smoothstep', - data: { edgeRole: 'branch' }, - }); - } - } - } - // Transition is terminal — don't advance chain - } else if (stmt.__kind === 'IfStatement') { - const ifStmt = stmt as { - condition?: { __emit?(ctx: { indent: number }): string }; - body: Statement[]; - orelse: Statement[]; - }; - const condText = ifStmt.condition?.__emit?.({ indent: 0 }) ?? ''; - const condId = `conditional-${counters.getCondIdx()}`; - - nodes.push({ - id: condId, - type: 'conditional', - position: { x: 0, y: 0 }, - data: { - nodeType: 'conditional', - label: 'Conditional', - subtitle: 'Conditional', - blockType: 'conditional', - conditionText: condText, - conditionLabel: abbreviateCondition(condText), - groupId, - }, - }); - - edges.push({ - id: `e-${curSource}-${condId}`, - source: curSource, - sourceHandle: curHandle, - target: condId, - type: 'smoothstep', - data: { edgeRole: 'branch' }, - }); - - // If branch — collect leaf nodes - const ifLeaves = buildDetailNodes( - ifStmt.body, - condId, - 'if', - nodes, - edges, - counters, - groupId - ); - leafNodeIds.push(...ifLeaves); - - // Else branch — collect leaf nodes - if (ifStmt.orelse.length > 0) { - const elseLeaves = buildDetailNodes( - ifStmt.orelse, - condId, - 'else', - nodes, - edges, - counters, - groupId - ); - leafNodeIds.push(...elseLeaves); - } - // 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 decomposed = decomposeAtMemberExpression(runStmt.target); - if (decomposed) { - const runId = `run-${counters.getRunIdx()}`; - nodes.push({ - id: runId, - type: 'run', - position: { x: 0, y: 0 }, - data: { - nodeType: 'run', - label: toDisplayLabel(decomposed.property), - subtitle: `@${decomposed.namespace}`, - blockType: 'actions', - groupId, - }, - }); - edges.push({ - id: `e-${curSource}-${runId}`, - source: curSource, - sourceHandle: curHandle, - target: runId, - type: 'smoothstep', - data: { edgeRole: 'branch' }, - }); - // Advance chain — next statement connects from this Run - curSource = runId; - curHandle = undefined; - - // Process nested body (runs can nest runs) - if (runStmt.body.length > 0) { - buildDetailNodes( - runStmt.body, - runId, - undefined, - nodes, - edges, - counters, - groupId - ); - } - } - } else if (stmt.__kind === 'SetClause') { - const setStmt = stmt as { - target: unknown; - value?: { __emit?(ctx: { indent: number }): string; value?: unknown }; - }; - const decomposed = decomposeAtMemberExpression(setStmt.target); - if (decomposed) { - const setId = `set-${counters.getSetIdx()}`; - const valueText = setStmt.value?.__emit - ? setStmt.value.__emit({ indent: 0 }) - : String(setStmt.value?.value ?? ''); - nodes.push({ - id: setId, - type: 'set', - position: { x: 0, y: 0 }, - data: { - nodeType: 'set', - label: `@${decomposed.namespace}.${decomposed.property}`, - subtitle: valueText, - blockType: 'set', - groupId, - }, - }); - edges.push({ - id: `e-${curSource}-${setId}`, - source: curSource, - sourceHandle: curHandle, - target: setId, - type: 'smoothstep', - data: { edgeRole: 'branch' }, - }); - // Advance chain — next statement connects from this Set - curSource = setId; - curHandle = undefined; - } - } else if (stmt.__kind === 'Template') { - const tpl = stmt as { - parts: Array<{ - __kind: string; - value?: string; - expression?: unknown; - }>; - }; - // Build a summary from template parts - const textParts: string[] = []; - for (const part of tpl.parts) { - if (part.__kind === 'TemplateText' && part.value) { - textParts.push(part.value.trim()); - } else if (part.__kind === 'TemplateInterpolation' && part.expression) { - const decomposed = decomposeAtMemberExpression(part.expression); - if (decomposed) { - textParts.push(`{@${decomposed.namespace}.${decomposed.property}}`); - } - } - } - const templateText = textParts.join(' ').trim(); - if (templateText) { - const tplId = `template-${counters.getTplIdx()}`; - nodes.push({ - id: tplId, - type: 'template', - position: { x: 0, y: 0 }, - data: { - nodeType: 'template', - label: templateText, - blockType: 'template', - groupId, - }, - }); - edges.push({ - id: `e-${curSource}-${tplId}`, - source: curSource, - sourceHandle: curHandle, - target: tplId, - type: 'smoothstep', - data: { edgeRole: 'branch' }, - }); - // Advance chain — templates append sequentially - curSource = tplId; - curHandle = undefined; - } - } - } - - // The final curSource (if it advanced beyond sourceId) is a leaf node. - // Transitions are excluded (they exit the loop, not converge). - if (curSource !== sourceId) { - const leafNode = nodes.find(n => n.id === curSource); - if ( - leafNode?.data.nodeType !== 'transition' && - leafNode?.data.nodeType !== 'set' - ) { - leafNodeIds.push(curSource); - } - } - - return leafNodeIds; -} - -/** - * Build detail graph nodes from a ReasoningActionBlock's statements. - * These contain ToClause (direct targets) and TransitionStatement wrappers. - */ -function buildDetailNodesFromReasoningAction( - statements: Statement[], - sourceId: string, - nodes: GraphNode[], - edges: GraphEdge[], - counters: IdCounters, - groupId?: string -): void { - for (const stmt of statements) { - if (stmt.__kind === 'ToClause') { - const toClause = stmt as { target: unknown }; - const target = resolveTransitionTarget(toClause.target); - if (target) { - const transId = `transition-${counters.getTransIdx()}`; - nodes.push({ - id: transId, - type: 'transition', - position: { x: 0, y: 0 }, - data: { - nodeType: 'transition', - label: toDisplayLabel(target), - subtitle: 'Transition', - blockType: 'topic', - transitionTarget: target, - groupId, - }, - }); - edges.push({ - id: `e-${sourceId}-${transId}`, - source: sourceId, - target: transId, - type: 'smoothstep', - data: { edgeRole: 'branch' }, - }); - } - } else if (stmt.__kind === 'TransitionStatement') { - const transition = stmt as { clauses: Statement[] }; - for (const clause of transition.clauses) { - if (clause.__kind === 'ToClause') { - const toClause = clause as { target: unknown }; - const target = resolveTransitionTarget(toClause.target); - if (target) { - const transId = `transition-${counters.getTransIdx()}`; - nodes.push({ - id: transId, - type: 'transition', - position: { x: 0, y: 0 }, - data: { - nodeType: 'transition', - label: toDisplayLabel(target), - subtitle: 'Transition', - blockType: 'topic', - transitionTarget: target, - groupId, - }, - }); - edges.push({ - id: `e-${sourceId}-${transId}`, - source: sourceId, - target: transId, - type: 'smoothstep', - data: { edgeRole: 'branch' }, - }); - } - } - } - } - } -} diff --git a/apps/ui/src/lib/ast-utils.ts b/apps/ui/src/lib/ast-utils.ts deleted file mode 100644 index fe80634d..00000000 --- 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/lib/block-type-config.tsx b/apps/ui/src/lib/block-type-config.tsx deleted file mode 100644 index 7c80c504..00000000 --- a/apps/ui/src/lib/block-type-config.tsx +++ /dev/null @@ -1,154 +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 type { ReactNode } from 'react'; -import { - Settings, - Cog, - Variable, - Play, - BookOpen, - Link, - Hash, - GitPullRequest, - Languages, - Users, -} from 'lucide-react'; - -export interface BlockTypeConfig { - icon: ReactNode; - iconBg: string; - iconClassName: string; - subtitle?: string; -} - -/** - * Single source of truth for block type visual configuration - * Used by: Canvas graph nodes, Explorer tree, BlockNote headers - */ -export function getBlockTypeConfig( - blockType: string, - options?: { - isStartAgent?: boolean; - iconSize?: number; - } -): BlockTypeConfig { - const { isStartAgent = false, iconSize = 16 } = options || {}; - - // Special case: start_agent — uses intelligence (indigo) color, it makes routing decisions - if ( - (blockType === 'topic' || - blockType === 'start_agent' || - blockType === 'subagent') && - isStartAgent - ) { - return { - icon: ( - - ), - iconClassName: 'text-indigo-500 dark:text-indigo-400', - iconBg: 'rgba(99,102,241,0.40)', - subtitle: 'Start Agent', - }; - } - - // Block type configurations - const configs: Record< - string, - Omit & { iconComponent: typeof Hash } - > = { - system: { - iconComponent: Settings, - iconClassName: 'text-blue-600', - iconBg: '#dbeafe', - }, - config: { - iconComponent: Cog, - iconClassName: 'text-purple-600', - iconBg: '#f3e8ff', - }, - variables: { - iconComponent: Variable, - iconClassName: 'text-orange-600', - iconBg: '#fed7aa', - }, - actions: { - iconComponent: Play, - iconClassName: 'text-green-600', - iconBg: '#d1fae5', - }, - knowledge: { - iconComponent: BookOpen, - iconClassName: 'text-teal-600', - iconBg: '#ccfbf1', - subtitle: 'Knowledge', - }, - knowledge_action: { - iconComponent: BookOpen, - iconClassName: 'text-teal-600', - iconBg: '#ccfbf1', - }, - language: { - iconComponent: Languages, - iconClassName: 'text-indigo-600', - iconBg: '#e0e7ff', - }, - connection: { - iconComponent: Link, - iconClassName: 'text-cyan-600', - iconBg: '#cffafe', - subtitle: 'Connection', - }, - topic: { - iconComponent: Hash, - iconClassName: 'text-sky-500 dark:text-sky-400', - iconBg: 'rgba(14,165,233,0.35)', - subtitle: 'Topic', - }, - subagent: { - iconComponent: Hash, - iconClassName: 'text-sky-500 dark:text-sky-400', - iconBg: 'rgba(14,165,233,0.35)', - subtitle: 'Subagent', - }, - start_agent: { - iconComponent: GitPullRequest, - iconClassName: 'text-indigo-500 dark:text-indigo-400', - iconBg: 'rgba(99,102,241,0.40)', - subtitle: 'Start Agent', - }, - related_agent: { - iconComponent: Users, - iconClassName: 'text-rose-600', - iconBg: '#ffe4e6', - subtitle: 'Related Agent', - }, - }; - - const config = configs[blockType]; - - if (config) { - const IconComponent = config.iconComponent; - return { - icon: , - iconClassName: config.iconClassName, - iconBg: config.iconBg, - subtitle: config.subtitle, - }; - } - - // Default fallback - return { - icon: , - iconClassName: 'text-gray-600', - iconBg: '#f3f4f6', - subtitle: blockType, - }; -} diff --git a/apps/ui/src/lib/graph-layout.ts b/apps/ui/src/lib/graph-layout.ts deleted file mode 100644 index 19dcde7c..00000000 --- a/apps/ui/src/lib/graph-layout.ts +++ /dev/null @@ -1,874 +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 - */ - -/** - * Graph Layout Engine - * - * Uses dagre for hierarchical layout of both overview and detail views. - * Nodes define multiple handles and edges are distributed across available - * handles to reduce visual overlap. - */ - -import dagre from '@dagrejs/dagre'; -import type { GraphNode, GraphEdge, GraphNodeType } from './ast-to-graph'; -import { - OVERVIEW_SIDES, - DETAIL_SIDES, - START_SIDES, - TERMINAL_SIDES, - PHASE_SIDES, - LLM_SIDES, - BUILD_INSTRUCTIONS_SIDES, - type HandleSide, -} from '~/components/graph/nodes/NodeHandles'; - -// --------------------------------------------------------------------------- -// Options -// --------------------------------------------------------------------------- - -export interface LayoutOptions { - direction: 'TB' | 'LR'; - /** When true, use detail handle side configs instead of overview. */ - isDetail?: boolean; - rankSep?: number; - nodeSep?: number; -} - -/** Default node dimensions per type. */ -const NODE_DIMENSIONS: Record< - GraphNodeType, - { width: number; height: number } -> = { - start: { width: 160, height: 56 }, - 'start-agent': { width: 260, height: 88 }, - topic: { width: 260, height: 88 }, - action: { width: 200, height: 70 }, - 'compound-topic': { width: 220, height: 200 }, - conditional: { width: 200, height: 56 }, - transition: { width: 200, height: 70 }, - run: { width: 200, height: 70 }, - set: { width: 240, height: 70 }, - template: { width: 300, height: 80 }, - phase: { width: 280, height: 56 }, - 'phase-label': { width: 280, height: 44 }, - llm: { width: 460, height: 72 }, - 'build-instructions': { width: 280, height: 44 }, - 'reasoning-group': { width: 10, height: 10 }, -}; - -function getNodeDimensions(node: GraphNode): { - width: number; - height: number; -} { - const base = NODE_DIMENSIONS[node.data.nodeType] ?? { - width: 200, - height: 70, - }; - - // Dynamic height for LLM nodes with action pills - if (node.data.nodeType === 'llm') { - const actions = node.data.actionNames as string[] | undefined; - if (actions && actions.length > 0) { - const pillRows = Math.ceil(actions.length / 4); - // border-t (1px) + py-3 (24px) + rows * (pill height ~26px + gap 8px) - const pillSectionHeight = 25 + pillRows * 28; - return { width: base.width, height: base.height + pillSectionHeight }; - } - } - - return base; -} - -// --------------------------------------------------------------------------- -// Handle side config per node type -// --------------------------------------------------------------------------- - -interface SideConfig { - type: 'source' | 'target'; -} - -function getOverviewSides( - nodeType: GraphNodeType -): Partial> { - switch (nodeType) { - case 'start': - return START_SIDES; - case 'transition': - return TERMINAL_SIDES; - default: - return OVERVIEW_SIDES; - } -} - -function getDetailSides( - nodeType: GraphNodeType -): Partial> { - switch (nodeType) { - case 'transition': - return TERMINAL_SIDES; - case 'phase': - case 'phase-label': - return PHASE_SIDES; - case 'llm': - return LLM_SIDES; - case 'build-instructions': - return BUILD_INSTRUCTIONS_SIDES; - case 'conditional': - return { top: { type: 'target' }, bottom: { type: 'source' } }; - case 'reasoning-group': - // Handles are rendered directly in the component (not via NodeHandles grid) - return {}; - default: - return DETAIL_SIDES; - } -} - -// --------------------------------------------------------------------------- -// Route point type (used by edge components for orthogonal routing) -// --------------------------------------------------------------------------- - -export interface RoutePoint { - x: number; - y: number; -} - -// --------------------------------------------------------------------------- -// Layout -// --------------------------------------------------------------------------- - -export interface LayoutResult { - nodes: GraphNode[]; - edges: GraphEdge[]; - /** Map of nodeId → Set of connected handle IDs (local, not prefixed) */ - connectedHandles: Map>; -} - -/** - * Dagre-based layout for the overview graph. - * - * Positions nodes in a hierarchical layered layout (TB) and distributes - * edges across available handles to reduce overlap. - * - * Synchronous — instant render. - */ -export function applyDagreOverviewLayout( - nodes: GraphNode[], - edges: GraphEdge[], - options: LayoutOptions -): LayoutResult { - if (nodes.length === 0) { - return { nodes: [], edges: [], connectedHandles: new Map() }; - } - - const g = new dagre.graphlib.Graph({ directed: true }); - g.setGraph({ - rankdir: options.direction, - nodesep: options.nodeSep ?? 80, - ranksep: options.rankSep ?? 120, - marginx: 40, - marginy: 40, - ranker: 'network-simplex', - }); - g.setDefaultNodeLabel(() => ({})); - g.setDefaultEdgeLabel(() => ({})); - - for (const node of nodes) { - const dims = getNodeDimensions(node); - g.setNode(node.id, { width: dims.width, height: dims.height }); - } - - for (const edge of edges) { - if (!g.hasNode(edge.source) || !g.hasNode(edge.target)) continue; - g.setEdge(edge.source, edge.target); - } - - dagre.layout(g); - - // Extract positions (dagre returns center coords → convert to top-left) - const positionedNodes = nodes.map(node => { - const dn = g.node(node.id) as - | { x: number; y: number; width: number; height: number } - | undefined; - return { - ...node, - position: { - x: dn ? dn.x - dn.width / 2 : 0, - y: dn ? dn.y - dn.height / 2 : 0, - }, - }; - }); - - // Assign handles to edges using handle pools (same approach as detail layout) - const sourcePool = new Map(); - const targetPool = new Map(); - const consumedSet = new Map>(); - - for (const node of nodes) { - const sides = getOverviewSides(node.data.nodeType); - - const sources: string[] = []; - if (sides.bottom?.type === 'source') sources.push('bottom'); - if (sides.right?.type === 'source') sources.push('right'); - - const targets: string[] = []; - if (sides.top?.type === 'target') targets.push('top'); - if (sides.left?.type === 'target') targets.push('left'); - - sourcePool.set(node.id, sources); - targetPool.set(node.id, targets); - consumedSet.set(node.id, new Set()); - } - - function consume(nodeId: string, handleId: string): void { - consumedSet.get(nodeId)?.add(handleId); - } - - function takeSource(nodeId: string): string { - const pool = sourcePool.get(nodeId) ?? []; - const used = consumedSet.get(nodeId) ?? new Set(); - for (const h of pool) { - if (!used.has(h)) { - consume(nodeId, h); - return h; - } - } - return 'bottom'; - } - - function takeTarget(nodeId: string): string { - const pool = targetPool.get(nodeId) ?? []; - const used = consumedSet.get(nodeId) ?? new Set(); - for (const h of pool) { - if (!used.has(h)) { - consume(nodeId, h); - return h; - } - } - return 'top'; - } - - // Reserve explicit handles first - for (const edge of edges) { - if (edge.sourceHandle) consume(edge.source, edge.sourceHandle); - if (edge.targetHandle) consume(edge.target, edge.targetHandle); - } - - const connectedHandles = new Map>(); - - const updatedEdges = edges.map(edge => { - const sourceHandle = edge.sourceHandle ?? takeSource(edge.source); - const targetHandle = edge.targetHandle ?? takeTarget(edge.target); - - trackHandle(connectedHandles, edge.source, sourceHandle); - trackHandle(connectedHandles, edge.target, targetHandle); - - return { - ...edge, - sourceHandle, - targetHandle, - }; - }); - - return { - nodes: positionedNodes, - edges: updatedEdges, - connectedHandles, - }; -} - -// --------------------------------------------------------------------------- -// Dagre Detail Layout (with React Flow parentId nesting) -// --------------------------------------------------------------------------- - -/** Padding inside group containers. */ -const GROUP_PAD = { x: 72, top: 64, bottom: 60 }; -const GROUP_PAD_EMPTY = { x: 36, top: 48, bottom: 36 }; - -/** Uniform width for spine nodes in detail view. */ -const SPINE_WIDTH = 460; - -/** - * Dagre-based layout for the topic detail view. - * - * Runs dagre on all non-group nodes, computes group bounding boxes from - * member positions, then converts child positions to parent-relative - * coordinates for React Flow's parentId nesting. - * - * Synchronous — instant render. - */ -export function applyDagreDetailLayout( - nodes: GraphNode[], - edges: GraphEdge[] -): LayoutResult { - if (nodes.length === 0) { - return { nodes: [], edges: [], connectedHandles: new Map() }; - } - - // ------------------------------------------------------------------------- - // 1. Build flat dagre graph (excluding group container nodes) - // ------------------------------------------------------------------------- - const g = new dagre.graphlib.Graph({ directed: true }); - g.setGraph({ - rankdir: 'TB', - nodesep: 80, - ranksep: 100, - marginx: 40, - marginy: 40, - ranker: 'network-simplex', - }); - g.setDefaultNodeLabel(() => ({})); - g.setDefaultEdgeLabel(() => ({})); - - const nodeMap = new Map(); - for (const node of nodes) { - nodeMap.set(node.id, node); - if (node.data.nodeType === 'reasoning-group') continue; - - const dims = getNodeDimensions(node); - g.setNode(node.id, { - width: node.data.isSpine ? SPINE_WIDTH : dims.width, - height: dims.height, - }); - } - - // Add edges with weights based on role: - // spine edges (high weight) keep the main flow tight and vertical - // branch/converge edges (low weight) allow horizontal spread - // cross-group spine edges get minlen=2 to prevent group overlap - for (const edge of edges) { - if (edge.type === 'loop-back') continue; - if (!g.hasNode(edge.source) || !g.hasNode(edge.target)) continue; - - const edgeData = edge.data as Record | undefined; - const edgeRole = edgeData?.edgeRole as string | undefined; - - // Detect cross-group boundary (different groupId on source vs target) - const sourceGroup = nodeMap.get(edge.source)?.data.groupId as - | string - | undefined; - const targetGroup = nodeMap.get(edge.target)?.data.groupId as - | string - | undefined; - const isCrossGroup = sourceGroup !== targetGroup; - - let weight = 1; - let minlen = 1; - if (edgeRole === 'spine') { - weight = 10; - // Extra rank separation at group boundaries for visual breathing room - minlen = isCrossGroup ? 3 : 1; - } else if (edgeRole === 'converge') { - weight = 1; - minlen = 1; - } else { - // branch edges - weight = 1; - minlen = 1; - } - - g.setEdge(edge.source, edge.target, { weight, minlen }); - } - - // Add bridge edges for edges that pass through group nodes. - // Group nodes aren't in dagre, so we create direct edges between the - // non-group endpoints to preserve layout ordering. - // Enter pair: A → group (t-c), group (enter-out) → B ⇒ bridge A → B - // Exit pair: A → group (exit-in), group (b-c) → B ⇒ bridge A → B - const groupIncoming = new Map< - string, - Array<{ source: string; targetHandle?: string | null }> - >(); - const groupOutgoing = new Map< - string, - Array<{ target: string; sourceHandle?: string | null }> - >(); - for (const edge of edges) { - if (edge.type === 'loop-back') continue; - const targetNode = nodeMap.get(edge.target); - const sourceNode = nodeMap.get(edge.source); - if (targetNode?.data.nodeType === 'reasoning-group') { - const arr = groupIncoming.get(edge.target) ?? []; - arr.push({ source: edge.source, targetHandle: edge.targetHandle }); - groupIncoming.set(edge.target, arr); - } - if (sourceNode?.data.nodeType === 'reasoning-group') { - const arr = groupOutgoing.get(edge.source) ?? []; - arr.push({ target: edge.target, sourceHandle: edge.sourceHandle }); - groupOutgoing.set(edge.source, arr); - } - } - for (const [groupId, inEdges] of groupIncoming) { - const outEdges = groupOutgoing.get(groupId); - if (!outEdges) continue; - for (const inc of inEdges) { - for (const out of outEdges) { - // Match enter pair (top / enter-out) or exit pair (exit-in / bottom) - const isEnter = - inc.targetHandle === 'top' && out.sourceHandle === 'enter-out'; - const isExit = - inc.targetHandle === 'exit-in' && out.sourceHandle === 'bottom'; - if ( - (isEnter || isExit) && - g.hasNode(inc.source) && - g.hasNode(out.target) - ) { - g.setEdge(inc.source, out.target, { weight: 10, minlen: 3 }); - } - } - } - } - - // Handle group-to-group spine transitions. - // When the spine passes through consecutive groups (e.g., before-reasoning → reasoning-loop), - // both endpoints are group nodes (not in dagre), so the standard bridge logic above fails. - // Fix: find the last real node exiting the source group and the first real node entering - // the target group, then create a bridge edge to preserve vertical ordering. - for (const edge of edges) { - if (edge.type === 'loop-back') continue; - const srcNode = nodeMap.get(edge.source); - const tgtNode = nodeMap.get(edge.target); - if ( - srcNode?.data.nodeType !== 'reasoning-group' || - tgtNode?.data.nodeType !== 'reasoning-group' - ) - continue; - - // Find the node flowing into the source group's exit-in handle - let exitNodeId: string | undefined; - for (const e of edges) { - if (e.target === edge.source && e.targetHandle === 'exit-in') { - exitNodeId = e.source; - break; - } - } - - // Find the node flowing out of the target group's enter-out handle - let enterNodeId: string | undefined; - for (const e of edges) { - if (e.source === edge.target && e.sourceHandle === 'enter-out') { - enterNodeId = e.target; - break; - } - } - - if ( - exitNodeId && - enterNodeId && - g.hasNode(exitNodeId) && - g.hasNode(enterNodeId) - ) { - g.setEdge(exitNodeId, enterNodeId, { weight: 10, minlen: 3 }); - } - } - - // ------------------------------------------------------------------------- - // 2. Run dagre layout - // ------------------------------------------------------------------------- - dagre.layout(g); - - // ------------------------------------------------------------------------- - // 3. Extract absolute positions (dagre returns center coords → top-left) - // ------------------------------------------------------------------------- - interface Rect { - x: number; - y: number; - w: number; - h: number; - } - const absPositions = new Map(); - - for (const nodeId of g.nodes()) { - const dn = g.node(nodeId) as - | { x: number; y: number; width: number; height: number } - | undefined; - if (!dn) continue; - absPositions.set(nodeId, { - x: dn.x - dn.width / 2, - y: dn.y - dn.height / 2, - w: dn.width, - h: dn.height, - }); - } - - // ------------------------------------------------------------------------- - // 4. Compute group bounding boxes from member absolute positions - // ------------------------------------------------------------------------- - const groupNodes = nodes.filter(n => n.data.nodeType === 'reasoning-group'); - const groupPositions = new Map(); - - for (const groupNode of groupNodes) { - const members: Rect[] = []; - for (const [id, pos] of absPositions) { - const n = nodeMap.get(id); - if (n?.data.groupId === groupNode.id) members.push(pos); - } - if (members.length === 0) continue; - - let minX = Infinity, - minY = Infinity, - maxX = -Infinity, - maxY = -Infinity; - for (const m of members) { - minX = Math.min(minX, m.x); - minY = Math.min(minY, m.y); - maxX = Math.max(maxX, m.x + m.w); - maxY = Math.max(maxY, m.y + m.h); - } - - const isEmpty = groupNode.data.isEmpty === true; - const pad = isEmpty ? GROUP_PAD_EMPTY : GROUP_PAD; - - groupPositions.set(groupNode.id, { - x: minX - pad.x, - y: minY - pad.top, - w: maxX - minX + pad.x * 2, - h: maxY - minY + pad.top + pad.bottom, - }); - } - - // ------------------------------------------------------------------------- - // 4b. Center all group containers on the spine - // Find the spine center X (from spine nodes), then horizontally center - // each group around it — widening narrower groups so they all share - // a common center line. - // ------------------------------------------------------------------------- - let spineCenterX: number | undefined; - for (const [id, pos] of absPositions) { - const n = nodeMap.get(id); - if (n?.data.isSpine) { - spineCenterX = pos.x + pos.w / 2; - break; - } - } - - if (spineCenterX !== undefined) { - // Find the max half-width across all groups so they all share - // the same width — aligned and centered on the spine. - let maxHalfWidth = 0; - for (const [, pos] of groupPositions) { - const leftDist = spineCenterX - pos.x; - const rightDist = pos.x + pos.w - spineCenterX; - maxHalfWidth = Math.max(maxHalfWidth, leftDist, rightDist); - } - - for (const [groupId, pos] of groupPositions) { - groupPositions.set(groupId, { - ...pos, - x: spineCenterX - maxHalfWidth, - w: maxHalfWidth * 2, - }); - } - } - - // ------------------------------------------------------------------------- - // 4c. Stretch LLM nodes to fill their group container width - // ------------------------------------------------------------------------- - for (const node of nodes) { - if (node.data.nodeType === 'llm' && node.data.groupId) { - const groupPos = groupPositions.get(node.data.groupId as string); - const abs = absPositions.get(node.id); - if (groupPos && abs) { - const pad = GROUP_PAD.x; - absPositions.set(node.id, { - x: groupPos.x + pad, - y: abs.y, - w: groupPos.w - pad * 2, - h: abs.h, - }); - } - } - } - - // ------------------------------------------------------------------------- - // 5. Build positioned node array - // - Group nodes first (React Flow requires parents before children) - // - Child positions converted to parent-relative coordinates - // ------------------------------------------------------------------------- - const positioned: GraphNode[] = []; - - // Group containers - for (const groupNode of groupNodes) { - const pos = groupPositions.get(groupNode.id); - if (!pos) continue; - positioned.push({ - ...groupNode, - position: { x: pos.x, y: pos.y }, - style: { width: pos.w, height: pos.h, zIndex: -1 }, - selectable: false, - draggable: false, - }); - } - - // All other nodes - for (const node of nodes) { - if (node.data.nodeType === 'reasoning-group') continue; - const absPos = absPositions.get(node.id); - if (!absPos) continue; - - let position = { x: absPos.x, y: absPos.y }; - - // Convert to parent-relative if this node has a parentId (group child) - if (node.parentId) { - const parentPos = groupPositions.get(node.parentId); - if (parentPos) { - position = { - x: absPos.x - parentPos.x, - y: absPos.y - parentPos.y, - }; - } - } - - positioned.push({ - ...node, - position, - ...(node.data.nodeType === 'llm' && node.parentId - ? { style: { width: absPos.w } } - : node.data.isSpine - ? { width: SPINE_WIDTH } - : {}), - }); - } - - // ------------------------------------------------------------------------- - // 6. Assign handles to edges + compute route points - // - Each handle used at most once per node - // - TB layout: prefer bottom handles for sources, top for targets - // ------------------------------------------------------------------------- - - // Build source/target handle pools per node based on side configs. - // Order: bottom/top preferred (TB primary), then side handles as overflow. - const sourcePool = new Map(); - const targetPool = new Map(); - const consumedSet = new Map>(); - - for (const node of nodes) { - if (node.data.nodeType === 'reasoning-group') continue; - const sides = getDetailSides(node.data.nodeType); - - const sources: string[] = []; - if (sides.bottom?.type === 'source') sources.push('bottom'); - if (sides.right?.type === 'source') sources.push('right'); - if (sides.left?.type === 'source') sources.push('left'); - - const targets: string[] = []; - if (sides.top?.type === 'target') targets.push('top'); - if (sides.left?.type === 'target') targets.push('left'); - if (sides.right?.type === 'target') targets.push('right'); - - sourcePool.set(node.id, sources); - targetPool.set(node.id, targets); - consumedSet.set(node.id, new Set()); - } - - function consume(nodeId: string, handleId: string): void { - consumedSet.get(nodeId)?.add(handleId); - } - - function takeSource(nodeId: string): string { - const pool = sourcePool.get(nodeId) ?? []; - const used = consumedSet.get(nodeId) ?? new Set(); - for (const h of pool) { - if (!used.has(h)) { - consume(nodeId, h); - return h; - } - } - return 'bottom'; - } - - function takeTarget(nodeId: string): string { - const pool = targetPool.get(nodeId) ?? []; - const used = consumedSet.get(nodeId) ?? new Set(); - for (const h of pool) { - if (!used.has(h)) { - consume(nodeId, h); - return h; - } - } - return 'top'; - } - - // Helper: check if a node is a group container (no handle pools) - const isGroupNode = (nodeId: string): boolean => - nodeMap.get(nodeId)?.data.nodeType === 'reasoning-group'; - - // Reserve handles that are already spoken for: - // - spine edges always use bottom → top (except for group-connected edges) - // - loop-back edges have pre-assigned handles - // - any edge with explicit sourceHandle/targetHandle from ast-to-graph - for (const edge of edges) { - if (edge.type === 'loop-back') { - if (edge.sourceHandle) consume(edge.source, edge.sourceHandle); - if (edge.targetHandle) consume(edge.target, edge.targetHandle); - continue; - } - const ed = edge.data as Record | undefined; - if ((ed?.edgeRole as string | undefined) === 'spine') { - // Group nodes have custom handles — don't reserve standard spine handles - if (!isGroupNode(edge.source)) consume(edge.source, 'bottom'); - if (!isGroupNode(edge.target)) consume(edge.target, 'top'); - } - if (edge.sourceHandle && !isGroupNode(edge.source)) - consume(edge.source, edge.sourceHandle); - if (edge.targetHandle && !isGroupNode(edge.target)) - consume(edge.target, edge.targetHandle); - } - - // Absolute handle position from node rect + handle ID - function handleAbsPos(rect: Rect, handleId: string): RoutePoint { - const { x, y, w, h: ht } = rect; - switch (handleId) { - case 'top': - return { x: x + w * 0.5, y }; - case 'bottom': - return { x: x + w * 0.5, y: y + ht }; - case 'left': - return { x, y: y + ht * 0.5 }; - case 'right': - return { x: x + w, y: y + ht * 0.5 }; - // Conditional node if/else handles at 30% and 70% along the bottom - case 'if': - return { x: x + w * 0.3, y: y + ht }; - case 'else': - return { x: x + w * 0.7, y: y + ht }; - // Custom handles for reasoning loop group enter/exit (at the border) - case 'enter-out': - return { x: x + w * 0.5, y }; - case 'exit-in': - return { x: x + w * 0.5, y: y + ht }; - default: - return { x: x + w * 0.5, y: y + ht }; - } - } - - const connectedHandles = new Map>(); - - const lookupAbs = (nodeId: string): Rect | undefined => - absPositions.get(nodeId) ?? groupPositions.get(nodeId); - - const updatedEdges = edges.map(edge => { - // Loop-back: keep pre-assigned handles, inject group container left X for edge snapping - if (edge.type === 'loop-back') { - trackHandle(connectedHandles, edge.source, edge.sourceHandle); - trackHandle(connectedHandles, edge.target, edge.targetHandle); - const sourceNode = nodeMap.get(edge.source); - const groupId = sourceNode?.data.groupId as string | undefined; - const groupPos = groupId ? groupPositions.get(groupId) : undefined; - return { - ...edge, - data: { - ...(edge.data as Record | undefined), - groupLeftX: groupPos?.x, - }, - }; - } - - const edgeData = edge.data as Record | undefined; - const edgeRole = edgeData?.edgeRole as string | undefined; - const isSpineEdge = edgeRole === 'spine'; - - let sourceHandle: string; - let targetHandle: string; - - if (isSpineEdge) { - // Group-connected spine edges use their explicit handles (enter-out, exit-in, etc.) - // rather than the standard bottom → top spine handles. - sourceHandle = isGroupNode(edge.source) - ? (edge.sourceHandle ?? 'bottom') - : 'bottom'; - targetHandle = isGroupNode(edge.target) - ? (edge.targetHandle ?? 'top') - : 'top'; - } else { - sourceHandle = edge.sourceHandle ?? takeSource(edge.source); - targetHandle = edge.targetHandle ?? takeTarget(edge.target); - } - - trackHandle(connectedHandles, edge.source, sourceHandle); - trackHandle(connectedHandles, edge.target, targetHandle); - - // Compute route points (absolute coords for React Flow edge rendering) - let elkPoints: RoutePoint[] | undefined; - - if (!isSpineEdge) { - const sourcePos = lookupAbs(edge.source); - const targetPos = lookupAbs(edge.target); - if (sourcePos && targetPos) { - const src = handleAbsPos(sourcePos, sourceHandle); - const tgt = handleAbsPos(targetPos, targetHandle); - const srcDown = - sourceHandle === 'bottom' || - sourceHandle === 'if' || - sourceHandle === 'else'; - const srcRight = sourceHandle === 'right'; - const tgtUp = targetHandle === 'top'; - const tgtLeft = targetHandle === 'left'; - - if (Math.abs(src.x - tgt.x) < 5) { - // Nearly aligned vertically — straight line - elkPoints = [src, tgt]; - } else if (srcDown && tgtUp) { - // Both vertical (bottom → top): step route via midpoint Y - const midY = (src.y + tgt.y) / 2; - elkPoints = [src, { x: src.x, y: midY }, { x: tgt.x, y: midY }, tgt]; - } else if (srcDown && tgtLeft) { - // Down from source, then across to left-side target - elkPoints = [src, { x: src.x, y: tgt.y }, tgt]; - } else if (srcRight && tgtUp) { - // Right from source, then up to top target - elkPoints = [src, { x: tgt.x, y: src.y }, tgt]; - } else if (srcRight && tgtLeft) { - // Horizontal: straight or S-bend - if (Math.abs(src.y - tgt.y) < 5) { - elkPoints = [src, tgt]; - } else { - const midX = (src.x + tgt.x) / 2; - elkPoints = [ - src, - { x: midX, y: src.y }, - { x: midX, y: tgt.y }, - tgt, - ]; - } - } else { - // Default: L-bend - elkPoints = [src, { x: tgt.x, y: src.y }, tgt]; - } - } - } - - return { - ...edge, - sourceHandle, - targetHandle, - data: { - ...(edgeData ?? {}), - ...(elkPoints ? { elkPoints } : {}), - }, - }; - }); - - return { - nodes: positioned, - edges: updatedEdges, - connectedHandles, - }; -} - -function trackHandle( - map: Map>, - nodeId: string, - handle: string | null | undefined -): void { - if (!handle) return; - let set = map.get(nodeId); - if (!set) { - set = new Set(); - map.set(nodeId, set); - } - set.add(handle); -} diff --git a/apps/ui/src/lib/graph-path.ts b/apps/ui/src/lib/graph-path.ts deleted file mode 100644 index fce77c9b..00000000 --- a/apps/ui/src/lib/graph-path.ts +++ /dev/null @@ -1,87 +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 - */ - -/** - * Graph Path Finding - * - * Finds all edges on simple (non-repeating) routes from start to target. - * Used for path highlighting when a node is selected. - */ - -import type { GraphEdge } from './ast-to-graph'; - -/** - * Find edges to highlight when selecting `targetId`, showing all simple - * routes from `startId` to `targetId`. - * - * Algorithm: - * 1. Backward BFS from target to find which nodes can reach the target. - * This prunes the search space so the DFS doesn't explore dead ends. - * 2. Forward DFS from start, only exploring nodes that can reach the target. - * The visited set prevents revisiting nodes, naturally handling cycles. - * 3. Every simple path found contributes its edges to the result set. - */ -export function findPathEdges( - edges: GraphEdge[], - startId: string, - targetId: string -): Set | null { - if (startId === targetId) return new Set(); - - // Build forward and reverse adjacency - const fwd = new Map>(); - const rev = new Map(); - for (const edge of edges) { - if (!fwd.has(edge.source)) fwd.set(edge.source, []); - fwd.get(edge.source)!.push({ target: edge.target, edgeId: edge.id }); - if (!rev.has(edge.target)) rev.set(edge.target, []); - rev.get(edge.target)!.push(edge.source); - } - - // Backward BFS: find all nodes that can reach the target - const canReachTarget = new Set([targetId]); - const queue = [targetId]; - while (queue.length > 0) { - const node = queue.shift()!; - for (const src of rev.get(node) ?? []) { - if (!canReachTarget.has(src)) { - canReachTarget.add(src); - queue.push(src); - } - } - } - - if (!canReachTarget.has(startId)) return null; - - // Forward DFS: enumerate all simple paths, collecting edges - const result = new Set(); - const pathEdges: string[] = []; - const visited = new Set([startId]); - - function dfs(current: string): boolean { - if (current === targetId) { - for (const edgeId of pathEdges) { - result.add(edgeId); - } - return true; - } - - let found = false; - for (const { target, edgeId } of fwd.get(current) ?? []) { - if (visited.has(target) || !canReachTarget.has(target)) continue; - visited.add(target); - pathEdges.push(edgeId); - if (dfs(target)) found = true; - pathEdges.pop(); - visited.delete(target); - } - return found; - } - - dfs(startId); - return result.size > 0 ? result : null; -} diff --git a/apps/ui/src/lib/graph-tokens.ts b/apps/ui/src/lib/graph-tokens.ts deleted file mode 100644 index 23259f3f..00000000 --- a/apps/ui/src/lib/graph-tokens.ts +++ /dev/null @@ -1,47 +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 - */ - -/** - * Centralized design tokens for graph components. - * - * Three semantic color roles: - * Intelligence (indigo) — reasoning, LLM, decisions, conditions - * Action (green) — start, run, execute - * Structure (slate) — topics, phases, containers, variables, transitions - */ -export const GRAPH = { - intelligence: { - accent: '#818cf8', - bg: 'rgba(99,102,241,0.15)', - text: '#c7d2fe', - }, - action: { - accent: '#4ade80', - bg: 'rgba(74,222,128,0.25)', - text: '#bbf7d0', - }, - structure: { - accent: '#94a3b8', - bg: 'rgba(148,163,184,0.10)', - text: '#cbd5e1', - }, - node: { - bg: '#26262e', - border: '#505060', - borderHover: '#606070', - }, - edge: { - primary: '#6366f1', - secondary: '#64748b', - highlight: '#3b82f6', - }, - text: { - primary: '#f1f5f9', - secondary: '#94a3b8', - tertiary: '#64748b', - }, -} as const; diff --git a/apps/ui/src/pages/Builder.tsx b/apps/ui/src/pages/Builder.tsx index 5fc9ca18..4924e5ba 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 da107eb4..41a1824c 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 56716501..1b5b8098 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 { From a3b768afe3fddfdce6d062160868afd980fcb23e Mon Sep 17 00:00:00 2001 From: Marcelino Llano Date: Mon, 4 May 2026 19:49:00 +0100 Subject: [PATCH 3/6] feat(vscode-webview): React webview bundling shared Graph New workspace package @agentscript/vscode-webview that renders the shared from @agentscript/graph-ui inside a VS Code webview. Vite + Tailwind + vite-plugin-singlefile produces a self-contained flow.html that the extension loads. - Parses the document source with @agentscript/agentforce on every update and feeds the AST into - Overview/detail navigation handled via local state and a floating back button (no router dependency) - Detects vscode-dark / vscode-light body class for theme - Added to pnpm-workspace.yaml via packages/vscode/webview --- packages/vscode/webview/flow.html | 16 +++ packages/vscode/webview/package.json | 32 ++++++ packages/vscode/webview/src/FlowApp.tsx | 124 ++++++++++++++++++++++++ packages/vscode/webview/src/flow.css | 11 +++ packages/vscode/webview/src/main.tsx | 17 ++++ packages/vscode/webview/tsconfig.json | 20 ++++ packages/vscode/webview/vite.config.ts | 28 ++++++ pnpm-workspace.yaml | 1 + 8 files changed, 249 insertions(+) create mode 100644 packages/vscode/webview/flow.html create mode 100644 packages/vscode/webview/package.json create mode 100644 packages/vscode/webview/src/FlowApp.tsx create mode 100644 packages/vscode/webview/src/flow.css create mode 100644 packages/vscode/webview/src/main.tsx create mode 100644 packages/vscode/webview/tsconfig.json create mode 100644 packages/vscode/webview/vite.config.ts diff --git a/packages/vscode/webview/flow.html b/packages/vscode/webview/flow.html new file mode 100644 index 00000000..0d628e22 --- /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 00000000..a260fe5c --- /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 00000000..ddde261e --- /dev/null +++ b/packages/vscode/webview/src/FlowApp.tsx @@ -0,0 +1,124 @@ +/* + * 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), []); + + 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 00000000..35cf8853 --- /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 00000000..6db1380f --- /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 00000000..01d975d1 --- /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 00000000..eeec619e --- /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-workspace.yaml b/pnpm-workspace.yaml index 7c45e183..e470fbf5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,6 @@ packages: - 'packages/*' + - 'packages/vscode/webview' - 'dialect/*' - 'apps/*' From a540119e9774865a066859f206ef434a8149a6b5 Mon Sep 17 00:00:00 2001 From: Marcelino Llano Date: Mon, 4 May 2026 19:50:00 +0100 Subject: [PATCH 4/6] feat(vscode): agent flow preview commands and webview panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds three commands modeled after Markdown Preview: - agentscript.flow.showPreview — open inline - agentscript.flow.showPreviewToSide — open to side (editor-title icon) - agentscript.flow.showSource — jump back to the .agent file Panel opens a WebviewPanel that loads the bundled flow.html from @agentscript/vscode-webview. Live-updates on document changes with 250ms debounce. Panels dedupe per-URI and restore across reloads via WebviewPanelSerializer. Build wiring: - esbuild.mjs runs the webview vite build when dist is missing and copies flow.html into dist/webview/ - @agentscript/vscode-webview added as a workspace devDep so turbo orders it before the extension build - .vscode/launch.json + tasks.json + scripts/build-all.mjs provide a single-entry F5 flow that rebuilds all workspace deps first --- .vscode/launch.json | 28 +++ .vscode/tasks.json | 24 +++ packages/vscode/esbuild.mjs | 29 ++++ packages/vscode/package.json | 42 +++++ packages/vscode/scripts/build-all.mjs | 67 ++++++++ packages/vscode/src/extension.ts | 3 + packages/vscode/src/previewFlow.ts | 236 ++++++++++++++++++++++++++ pnpm-lock.yaml | 140 +++++++++++++++ 8 files changed, 569 insertions(+) create mode 100644 .vscode/launch.json create mode 100644 .vscode/tasks.json create mode 100644 packages/vscode/scripts/build-all.mjs create mode 100644 packages/vscode/src/previewFlow.ts diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..c5521de2 --- /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 00000000..9258e554 --- /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/packages/vscode/esbuild.mjs b/packages/vscode/esbuild.mjs index 6c2cc76b..a01f0b71 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 7724ce17..d617719c 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -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 00000000..4bc1ea4a --- /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 4bc31420..9a6269fb 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 00000000..bb5bf2e0 --- /dev/null +++ b/packages/vscode/src/previewFlow.ts @@ -0,0 +1,236 @@ +/* + * 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 + } + } + } + + 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/pnpm-lock.yaml b/pnpm-lock.yaml index 2271c918..57b8927f 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) From 24aad5f8c8d16180d5bb6ae5dbc40fb962eed91f Mon Sep 17 00:00:00 2001 From: Marcelino Llano Date: Mon, 4 May 2026 20:35:19 +0100 Subject: [PATCH 5/6] feat(vscode): jump to source on node click in flow preview Attaches CST-derived source ranges to every graph node that maps to a parsed block, then posts a goto-source message on single-click so the VS Code webview can select the underlying range in the editor. - Adds SourceRange type and getSourceRange helper in graph-ui - Attaches sourceRange to topic, start_agent, topic header, phase containers, reasoning loop / build-instructions / LLM node, and every statement-backed detail node (action, run, set, template, conditional, transition) - LLM action pills carry parallel actionRanges; onActionClick now includes the range so pill clicks jump to the reasoning action block - previewFlow.ts handles goto-source by opening the document with the selected range --- packages/graph-ui/src/ast/ast-to-graph.ts | 57 +++++++++++++++++++ .../graph-ui/src/components/nodes/LlmNode.tsx | 6 +- .../graph-ui/src/context/GraphContext.tsx | 1 + packages/graph-ui/src/index.ts | 2 + packages/vscode/src/previewFlow.ts | 28 +++++++++ packages/vscode/webview/src/FlowApp.tsx | 20 +++++++ 6 files changed, 113 insertions(+), 1 deletion(-) diff --git a/packages/graph-ui/src/ast/ast-to-graph.ts b/packages/graph-ui/src/ast/ast-to-graph.ts index ba7c60e9..9829331d 100644 --- a/packages/graph-ui/src/ast/ast-to-graph.ts +++ b/packages/graph-ui/src/ast/ast-to-graph.ts @@ -52,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', @@ -74,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; @@ -89,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. */ @@ -502,6 +538,7 @@ export function astToOverviewGraph(ast: AgentScriptAST): { isStartAgent: true, topicName: name, diagnostics: blockDiagnostics, + sourceRange: getSourceRange(block), }, }); @@ -534,6 +571,7 @@ export function astToOverviewGraph(ast: AgentScriptAST): { blockType: 'topic', topicName: name, diagnostics: blockDiagnostics, + sourceRange: getSourceRange(block), }, }); @@ -658,6 +696,7 @@ export function astToTopicDetailGraph( phaseType: 'topic-header', topicName, diagnostics: topicDiagnostics, + sourceRange: getSourceRange(topicBlock), }, }); connectPipeline(headerId); @@ -678,6 +717,7 @@ export function astToTopicDetailGraph( label: 'Before Reasoning', blockType: 'topic', isEmpty: beforeEmpty, + sourceRange: getSourceRange(topicBlock.before_reasoning), }, }); @@ -696,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: @@ -767,6 +808,7 @@ export function astToTopicDetailGraph( nodeType: 'reasoning-group', label: 'Reasoning Loop', blockType: 'topic', + sourceRange: getSourceRange(reasoning), }, }); @@ -794,6 +836,7 @@ export function astToTopicDetailGraph( blockType: 'topic', phaseType: 'before_reasoning_iteration', groupId: GROUP_IDS.reasoningLoop, + sourceRange: getSourceRange(reasoning?.before_reasoning_iteration), }, }); @@ -853,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 @@ -885,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 = @@ -892,6 +937,7 @@ export function astToTopicDetailGraph( toDisplayLabel(actionName); actionDisplayNames.push(actionLabel); actionKeyNames.push(actionName); + actionRanges.push(getSourceRange(actionBlock)); } } @@ -908,7 +954,10 @@ export function astToTopicDetailGraph( groupId: GROUP_IDS.reasoningLoop, actionNames: actionDisplayNames, actionKeys: actionKeyNames, + actionRanges, topicName, + sourceRange: + getSourceRange(reasoning?.actions) ?? getSourceRange(reasoning), }, }); connectPipeline(llmId); @@ -971,6 +1020,7 @@ export function astToTopicDetailGraph( label: 'After Reasoning', blockType: 'topic', isEmpty: afterEmpty, + sourceRange: getSourceRange(topicBlock.after_reasoning), }, }); @@ -987,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: @@ -1117,6 +1168,7 @@ function buildDetailNodes( blockType: 'topic', transitionTarget: target, groupId, + sourceRange: getSourceRange(stmt), }, }); edges.push({ @@ -1152,6 +1204,7 @@ function buildDetailNodes( conditionText: condText, conditionLabel: abbreviateCondition(condText), groupId, + sourceRange: getSourceRange(stmt), }, }); @@ -1205,6 +1258,7 @@ function buildDetailNodes( subtitle: `@${decomposed.namespace}`, blockType: 'actions', groupId, + sourceRange: getSourceRange(stmt), }, }); edges.push({ @@ -1253,6 +1307,7 @@ function buildDetailNodes( subtitle: valueText, blockType: 'set', groupId, + sourceRange: getSourceRange(stmt), }, }); edges.push({ @@ -1299,6 +1354,7 @@ function buildDetailNodes( label: templateText, blockType: 'template', groupId, + sourceRange: getSourceRange(stmt), }, }); edges.push({ @@ -1389,6 +1445,7 @@ function buildDetailNodesFromReasoningAction( blockType: 'topic', transitionTarget: target, groupId, + sourceRange: getSourceRange(stmt), }, }); edges.push({ diff --git a/packages/graph-ui/src/components/nodes/LlmNode.tsx b/packages/graph-ui/src/components/nodes/LlmNode.tsx index 6c608036..a2bd9d76 100644 --- a/packages/graph-ui/src/components/nodes/LlmNode.tsx +++ b/packages/graph-ui/src/components/nodes/LlmNode.tsx @@ -7,12 +7,15 @@ import type { NodeProps } from '@xyflow/react'; import { Sparkles, Play } from 'lucide-react'; -import type { GraphNode } from '../../ast/ast-to-graph'; +import type { GraphNode, SourceRange } from '../../ast/ast-to-graph'; import { NodeHandles, LLM_SIDES } from './NodeHandles'; import { useGraphContext } from '../../context/GraphContext'; 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 { onActionClick } = useGraphContext(); @@ -26,6 +29,7 @@ export function LlmNode({ data }: NodeProps) { actionDisplayName: actionName, actionIndex: index, topicName: data.topicName as string | undefined, + sourceRange: actionRanges?.[index], }); }; diff --git a/packages/graph-ui/src/context/GraphContext.tsx b/packages/graph-ui/src/context/GraphContext.tsx index 2f3a32a3..c27e1439 100644 --- a/packages/graph-ui/src/context/GraphContext.tsx +++ b/packages/graph-ui/src/context/GraphContext.tsx @@ -11,6 +11,7 @@ export interface ActionClickPayload { actionDisplayName: string; actionIndex: number; topicName: string | undefined; + sourceRange?: import('../ast/ast-to-graph').SourceRange; } export interface ConditionalClickPayload { diff --git a/packages/graph-ui/src/index.ts b/packages/graph-ui/src/index.ts index d6cef2e9..b634654d 100644 --- a/packages/graph-ui/src/index.ts +++ b/packages/graph-ui/src/index.ts @@ -21,6 +21,8 @@ export { type ActionDrawerData, type NodeDrawerData, type GraphDrawerPayload, + type SourceRange, + getSourceRange, } from './ast/ast-to-graph'; export { applyDagreOverviewLayout, diff --git a/packages/vscode/src/previewFlow.ts b/packages/vscode/src/previewFlow.ts index bb5bf2e0..63e6df22 100644 --- a/packages/vscode/src/previewFlow.ts +++ b/packages/vscode/src/previewFlow.ts @@ -150,6 +150,34 @@ class FlowPanelManager { } 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 + } } } diff --git a/packages/vscode/webview/src/FlowApp.tsx b/packages/vscode/webview/src/FlowApp.tsx index ddde261e..b7a2d4de 100644 --- a/packages/vscode/webview/src/FlowApp.tsx +++ b/packages/vscode/webview/src/FlowApp.tsx @@ -95,6 +95,24 @@ export function FlowApp() { 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 ? ( From 7c532f069e4339bb19173d555a97f27bb3ddf4c2 Mon Sep 17 00:00:00 2001 From: Marcelino Llano Date: Fri, 22 May 2026 12:58:32 +0200 Subject: [PATCH 6/6] chore(vscode): bump to 2.3.0 with flow preview changelog --- packages/vscode/CHANGELOG.md | 6 ++++++ packages/vscode/package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index 81fa4181..f20daa40 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.3.0] - 2026-05-04 + +### 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.2.2] - 2026-4-10 ### Added diff --git a/packages/vscode/package.json b/packages/vscode/package.json index d617719c..69145ed6 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.2.2", + "version": "2.3.0", "private": true, "publisher": "Salesforce", "license": "Apache-2.0",