diff --git a/frontend/src/app/canvas/CanvasPage.tsx b/frontend/src/app/canvas/CanvasPage.tsx index 4722eb2..216a0e6 100644 --- a/frontend/src/app/canvas/CanvasPage.tsx +++ b/frontend/src/app/canvas/CanvasPage.tsx @@ -66,6 +66,7 @@ import { getNodeType, isConnectionAllowed, } from '@/lib/nodeRegistry' +import { getPathNodeIds } from '@/lib/graphPath' import type { AppNode, AppEdge } from '@/lib/nodeTypes' import { toast } from 'sonner' import { @@ -77,6 +78,8 @@ import { const SNAP_GRID: [number, number] = [15, 15] const DUPLICATE_OFFSET = { x: 30, y: 30 } +/** Minimum time (ms) the connection ant trail runs when a path update is in progress. */ +const CONNECTION_PATH_UPDATE_MIN_MS = 1500 const snapToGrid = (x: number, y: number): { x: number; y: number } => ({ x: Math.round(x / SNAP_GRID[0]) * SNAP_GRID[0], y: Math.round(y / SNAP_GRID[1]) * SNAP_GRID[1], @@ -264,6 +267,75 @@ export function CanvasPage({ projectId }: CanvasPageProps) { const [isSelecting, setIsSelecting] = React.useState(false) const [ariaAnnouncement, setAriaAnnouncement] = React.useState(null) const [fullscreenNodeId, setFullscreenNodeId] = React.useState(null) + const [connectionPathUpdatingNodeIds, setConnectionPathUpdatingNodeIds] = React.useState([]) + const [connectionPathTriggerNodeIds, setConnectionPathTriggerNodeIds] = React.useState([]) + const pathUpdateNodeIdsRef = useRef>(new Set()) + const pathUpdateStartTimeRef = useRef(null) + const pathUpdateEndTimeoutRef = useRef | null>(null) + + const clearPathUpdateSession = useCallback(() => { + setConnectionPathUpdatingNodeIds([]) + setConnectionPathTriggerNodeIds([]) + }, []) + + const startConnectionPathUpdate = useCallback((nodeId: string) => { + const ref = pathUpdateNodeIdsRef.current + ref.add(nodeId) + if (ref.size === 1) { + pathUpdateStartTimeRef.current = Date.now() + if (pathUpdateEndTimeoutRef.current != null) { + clearTimeout(pathUpdateEndTimeoutRef.current) + pathUpdateEndTimeoutRef.current = null + } + } + setConnectionPathUpdatingNodeIds(Array.from(ref)) + }, []) + + const endConnectionPathUpdate = useCallback((nodeId: string) => { + const ref = pathUpdateNodeIdsRef.current + ref.delete(nodeId) + if (ref.size > 0) { + setConnectionPathUpdatingNodeIds(Array.from(ref)) + return + } + const startedAt = pathUpdateStartTimeRef.current ?? 0 + const elapsed = Date.now() - startedAt + const remaining = Math.max(0, CONNECTION_PATH_UPDATE_MIN_MS - elapsed) + if (remaining === 0) { + clearPathUpdateSession() + } else { + pathUpdateEndTimeoutRef.current = setTimeout(() => { + pathUpdateEndTimeoutRef.current = null + clearPathUpdateSession() + }, remaining) + } + }, [clearPathUpdateSession]) + + const pathTriggerBatchRef = useRef>(new Set()) + const pathTriggerScheduledRef = useRef(false) + const addConnectionPathTrigger = useCallback((nodeId: string) => { + pathTriggerBatchRef.current.add(nodeId) + if (pathTriggerScheduledRef.current) return + pathTriggerScheduledRef.current = true + requestAnimationFrame(() => { + pathTriggerScheduledRef.current = false + const batch = new Set(pathTriggerBatchRef.current) + pathTriggerBatchRef.current = new Set() + if (batch.size === 0) return + setConnectionPathTriggerNodeIds((prev) => { + const next = new Set(prev) + batch.forEach((id) => next.add(id)) + return next.size === prev.length && prev.every((id) => next.has(id)) ? prev : Array.from(next) + }) + }) + }, []) + + React.useEffect(() => () => { + if (pathUpdateEndTimeoutRef.current != null) { + clearTimeout(pathUpdateEndTimeoutRef.current) + } + }, []) + const nodesRef = useRef(nodes) nodesRef.current = nodes @@ -471,6 +543,11 @@ export function CanvasPage({ projectId }: CanvasPageProps) { flowActionsRef.current?.pasteAtViewportCenter?.() }, []) + const connectionPathNodeIds = useMemo( + () => getPathNodeIds(edges, connectionPathUpdatingNodeIds, connectionPathTriggerNodeIds), + [edges, connectionPathUpdatingNodeIds, connectionPathTriggerNodeIds] + ) + const flowContextValue = useMemo( () => ({ nodes, @@ -485,6 +562,12 @@ export function CanvasPage({ projectId }: CanvasPageProps) { flowActionsRef, fullscreenNodeId, setFullscreenNodeId, + connectionPathUpdatingNodeIds, + connectionPathTriggerNodeIds, + addConnectionPathTrigger, + connectionPathNodeIds, + startConnectionPathUpdate, + endConnectionPathUpdate, }), [ nodes, @@ -499,6 +582,12 @@ export function CanvasPage({ projectId }: CanvasPageProps) { flowActionsRef, fullscreenNodeId, setFullscreenNodeId, + connectionPathUpdatingNodeIds, + connectionPathTriggerNodeIds, + addConnectionPathTrigger, + connectionPathNodeIds, + startConnectionPathUpdate, + endConnectionPathUpdate, ] ) diff --git a/frontend/src/app/canvas/ContextualZoomNode.tsx b/frontend/src/app/canvas/ContextualZoomNode.tsx index 0b9a6f8..558b2ee 100644 --- a/frontend/src/app/canvas/ContextualZoomNode.tsx +++ b/frontend/src/app/canvas/ContextualZoomNode.tsx @@ -79,6 +79,8 @@ function CompactNodeView({ id, type, selected, width, height }: NodeProps) { /** * Wraps a node component so that when the viewport zoom is at or below * CONTEXTUAL_ZOOM_THRESHOLD, the node renders as a compact icon-only view. + * Inner is always mounted (hidden when compact) so switching zoom does not + * remount and re-trigger effects (e.g. RenderingNode fetch). */ export function createContextualNode

( Inner: React.ComponentType

@@ -86,22 +88,35 @@ export function createContextualNode

( function ContextualZoomNode(props: P) { const { zoom } = useViewport() const showCompact = zoom <= CONTEXTUAL_ZOOM_THRESHOLD + const p = props as NodeProps + const type = p.type + const defaultStyle = type ? getDefaultStyle(type) : { width: 320, height: 320 } + const w = p.width ?? defaultStyle.width + const h = p.height ?? defaultStyle.height - if (showCompact) { - const p = props as NodeProps - return ( - - ) - } - - return + return ( +

+ {showCompact && ( + + )} +
+ +
+
+ ) } ContextualZoomNode.displayName = `ContextualZoom(${Inner.displayName ?? Inner.name ?? 'Node'})` diff --git a/frontend/src/components/base/AnimatedEdge.tsx b/frontend/src/components/base/AnimatedEdge.tsx index 67b6bbc..665484b 100644 --- a/frontend/src/components/base/AnimatedEdge.tsx +++ b/frontend/src/components/base/AnimatedEdge.tsx @@ -9,9 +9,11 @@ import { getConnectionLabelForTarget } from '../../lib/nodeRegistry' const EDGE_STROKE_WIDTH = 2 const DOT_MARKER_R = 1.5 +const EMPTY_PATH_NODE_IDS = new Set() export function AnimatedEdge({ id, + source, sourceX, sourceY, targetX, @@ -25,6 +27,7 @@ export function AnimatedEdge({ }: EdgeProps) { const ctx = useContext(FlowContext) const nodes = ctx?.nodes ?? [] + const pathNodeIds = ctx?.connectionPathNodeIds ?? EMPTY_PATH_NODE_IDS const targetNode = useMemo(() => nodes.find((n: any) => n.id === target), [nodes, target]) const derivedLabel = useMemo( () => (targetNode?.type != null ? getConnectionLabelForTarget(targetNode.type) : undefined), @@ -32,6 +35,8 @@ export function AnimatedEdge({ ) const label = labelProp ?? derivedLabel + const isOnUpdatingPath = pathNodeIds.has(source) && pathNodeIds.has(target) + const [edgePath, edgeLabelX, edgeLabelY] = getBezierPath({ sourceX, sourceY, @@ -88,7 +93,7 @@ export function AnimatedEdge({ strokeWidth: EDGE_STROKE_WIDTH, ...style, }} - className="animated-edge-path" + className={`animated-edge-path${isOnUpdatingPath ? ' animated-edge-path--updating' : ''}`} interactionWidth={interactionWidth} /> {label != null && ( diff --git a/frontend/src/components/base/BaseNode.tsx b/frontend/src/components/base/BaseNode.tsx index ff070ef..602a1d7 100644 --- a/frontend/src/components/base/BaseNode.tsx +++ b/frontend/src/components/base/BaseNode.tsx @@ -1,6 +1,7 @@ import type { ComponentProps, ReactNode } from "react"; import { NodeResizer } from "@xyflow/react"; +import { useConnectionPathRole } from "@/lib/flowContext"; import { cn } from "@/lib/utils"; /** Default min size for resizable nodes (used by NodeResizer). */ @@ -34,6 +35,7 @@ export function BaseNode({ resizeConstraints, ...props }: BaseNodeProps) { + const connectionPathRole = useConnectionPathRole(nodeId); const hasSize = dimensions && dimensions.width > 0 && @@ -62,9 +64,13 @@ export function BaseNode({ "bg-card text-card-foreground relative rounded-md border transition-[border-color,box-shadow] duration-200", "hover:ring-1", selected && "border-primary/50 shadow-[0_0_0_2px_hsl(var(--primary)_/_0.15)] dark:border-primary/35 dark:shadow-[0_0_0_2px_hsl(var(--primary)_/_0.1)]", + connectionPathRole === "trigger" && "connection-path-trigger", + connectionPathRole === "updating" && "connection-path-updating", + connectionPathRole === "on-path" && "connection-path-on-path", className, )} data-selected={selected} + data-path-role={connectionPathRole ?? undefined} style={appliedStyle} tabIndex={0} {...props} diff --git a/frontend/src/components/nodes/AgentNode.tsx b/frontend/src/components/nodes/AgentNode.tsx index 2da9ebe..b64b3e0 100644 --- a/frontend/src/components/nodes/AgentNode.tsx +++ b/frontend/src/components/nodes/AgentNode.tsx @@ -55,6 +55,8 @@ function serializeNodeForContext(nodes: { id: string; type?: string; data?: unkn function AgentNodeComponent({ id, data, width, height, selected }: Props) { const flowContext = useContext(FlowContext) const setFullscreenNodeId = flowContext?.setFullscreenNodeId + const startConnectionPathUpdate = flowContext?.startConnectionPathUpdate + const endConnectionPathUpdate = flowContext?.endConnectionPathUpdate const supportsFullscreen = getNodeType('agent')?.supportsFullscreen const { nodes, sourceIds, updateData } = useAbstractNode(id, data ?? {}) const { aiConnection } = usePlatform() @@ -88,6 +90,7 @@ function AgentNodeComponent({ id, data, width, height, selected }: Props) { const contextNodes = sourceIds.map((sid) => ({ id: sid, content: serializeNodeForContext(nodes, sid) })) updateData({ error: undefined, loading: true }) + startConnectionPathUpdate?.(id) setRunning(true) try { const res = await fetch('/api/agent', { @@ -107,16 +110,19 @@ function AgentNodeComponent({ id, data, width, height, selected }: Props) { error: (json as { error?: string }).error ?? `Request failed: ${res.status}`, outputMarkdown: undefined, }) + endConnectionPathUpdate?.(id) return } const markdown = (json as { markdown?: string }).markdown ?? '' updateData({ loading: false, error: undefined, outputMarkdown: markdown }) + endConnectionPathUpdate?.(id) } catch (err: unknown) { updateData({ loading: false, error: err instanceof Error ? err.message : 'Agent request failed', outputMarkdown: undefined, }) + endConnectionPathUpdate?.(id) } finally { setRunning(false) } @@ -131,6 +137,7 @@ function AgentNodeComponent({ id, data, width, height, selected }: Props) { diff --git a/frontend/src/components/nodes/RenderingNode.tsx b/frontend/src/components/nodes/RenderingNode.tsx index 474ff26..2d04466 100644 --- a/frontend/src/components/nodes/RenderingNode.tsx +++ b/frontend/src/components/nodes/RenderingNode.tsx @@ -50,6 +50,8 @@ type ViewMode = 'preview' | 'raw' function RenderingNodeComponent({ id, data, width, height, selected }: Props) { const flowContext = useContext(FlowContext) const setFullscreenNodeId = flowContext?.setFullscreenNodeId + const startConnectionPathUpdate = flowContext?.startConnectionPathUpdate + const endConnectionPathUpdate = flowContext?.endConnectionPathUpdate const supportsFullscreen = getNodeType('render')?.supportsFullscreen const [renderedContent, setRenderedContent] = useState(null) const [resolvedContent, setResolvedContent] = useState(null) @@ -61,6 +63,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) { const runIdRef = useRef(0) const loadingStartedAtRef = useRef(null) const minLoadingTimeoutRef = useRef | null>(null) + const isInitialRunRef = useRef(true) const { nodes, edges, setNodes, setEdges, sourceIds, updateData } = useAbstractNode(id, data ?? {}) const viewportWidth = data?.viewportWidth ?? DEFAULT_VIEWPORT_WIDTH const viewportHeight = data?.viewportHeight ?? DEFAULT_VIEWPORT_HEIGHT @@ -195,6 +198,8 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) { message: isAgentSource ? 'Run the Agent node to generate output.' : 'No content on connected configuration node', }) setLoading(false) + if (!isInitialRunRef.current) endConnectionPathUpdate?.(id) + isInitialRunRef.current = false return } if (incomingIds.length === 0) { @@ -202,6 +207,8 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) { setResolvedContent(null) setError(null) setLoading(false) + if (!isInitialRunRef.current) endConnectionPathUpdate?.(id) + isInitialRunRef.current = false return } @@ -212,6 +219,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) { const run = async () => { loadingStartedAtRef.current = Date.now() setLoading(true) + if (!isInitialRunRef.current) startConnectionPathUpdate?.(id) setError(null) try { if (srcNode?.type === 'agent') { @@ -223,6 +231,8 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) { setRenderedContent(html) setError(null) setLoading(false) + if (!isInitialRunRef.current) endConnectionPathUpdate?.(id) + isInitialRunRef.current = false return } const configIdsUsed = new Set() @@ -497,6 +507,8 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) { setResolvedContent(null) setError({ kind: 'render', message: `Nunjucks: ${nunjucksErr.message}` }) setLoading(false) + if (!isInitialRunRef.current) endConnectionPathUpdate?.(id) + isInitialRunRef.current = false return } @@ -524,10 +536,16 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) { if (remaining > 0) { minLoadingTimeoutRef.current = setTimeout(() => { minLoadingTimeoutRef.current = null - if (!cancelled && thisRunId === runIdRef.current) setLoading(false) + if (!cancelled && thisRunId === runIdRef.current) { + setLoading(false) + if (!isInitialRunRef.current) endConnectionPathUpdate?.(id) + isInitialRunRef.current = false + } }, remaining) } else { setLoading(false) + if (!isInitialRunRef.current) endConnectionPathUpdate?.(id) + isInitialRunRef.current = false } } } @@ -537,6 +555,8 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) { setRenderedContent(null) setError({ kind: 'render', message: err?.message ?? 'Render error' }) setLoading(false) + if (!isInitialRunRef.current) endConnectionPathUpdate?.(id) + isInitialRunRef.current = false } } } @@ -549,6 +569,8 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) { clearTimeout(minLoadingTimeoutRef.current) minLoadingTimeoutRef.current = null } + if (!isInitialRunRef.current) endConnectionPathUpdate?.(id) + isInitialRunRef.current = false } // Only re-run when inputs that affect the resolved output change (signatures + source). Debounced to avoid excessive re-renders while typing. retryCount triggers re-run on Retry. }, [id, sourceContent, configTypeId, configSignature, edgesSignature, variablesSignature, functionsSignature, dataSignature, viewportWidth, viewportHeight, retryCount, isAgentSource]) diff --git a/frontend/src/components/nodes/VariableNode.tsx b/frontend/src/components/nodes/VariableNode.tsx index a9fc806..ff6d9d0 100644 --- a/frontend/src/components/nodes/VariableNode.tsx +++ b/frontend/src/components/nodes/VariableNode.tsx @@ -84,7 +84,7 @@ function VariableNodeComponent({ id, data, width, height, selected }: Props) { ) return ( - }> + }> } title={} /> diff --git a/frontend/src/lib/abstractNode.ts b/frontend/src/lib/abstractNode.ts index c2a85a2..894ea69 100644 --- a/frontend/src/lib/abstractNode.ts +++ b/frontend/src/lib/abstractNode.ts @@ -72,6 +72,7 @@ export function useAbstractNode>( const setNodes = ctx?.setNodes const setEdges = ctx?.setEdges + const addConnectionPathTrigger = ctx?.addConnectionPathTrigger const updateData = useCallback( (partial: Partial) => { if (!setNodes) return @@ -80,8 +81,9 @@ export function useAbstractNode>( n.id === id ? { ...n, data: { ...(n.data as object), ...partial } } : n ) as AppNode[] ) + addConnectionPathTrigger?.(id) }, - [id, setNodes] + [id, setNodes, addConnectionPathTrigger] ) const incomingEdges = useMemo( diff --git a/frontend/src/lib/flowContext.tsx b/frontend/src/lib/flowContext.tsx index 5777fbc..2038e21 100644 --- a/frontend/src/lib/flowContext.tsx +++ b/frontend/src/lib/flowContext.tsx @@ -1,9 +1,12 @@ -import React from 'react' +import React, { useMemo } from 'react' import type { Connection } from '@xyflow/react' import type { AppNode, AppEdge } from '@/lib/nodeTypes' export type ConnectionFrom = { nodeId: string; sourceHandle?: string } | null +/** Role of a node in the current connection path update: pushing data, receiving/loading, or just on path. */ +export type ConnectionPathRole = 'trigger' | 'updating' | 'on-path' + export type FlowActions = { pasteAtViewportCenter: () => void fitView: () => void @@ -25,8 +28,38 @@ export type FlowContextValue = { /** When set, graph centers on this node and a fullscreen dialog shows the node. Cleared on close. */ fullscreenNodeId: string | null setFullscreenNodeId: (id: string | null) => void + /** Node ids currently updating (e.g. render loading, agent running). Only edges on those paths show the ant trail. */ + connectionPathUpdatingNodeIds: string[] + /** Node ids that triggered the current update (e.g. variable/config that changed). Path is restricted to downstream(trigger) ∩ upstream(updating). */ + connectionPathTriggerNodeIds: string[] + /** Call when this node's output changed and may trigger downstream updates (e.g. variable value, config content). */ + addConnectionPathTrigger: (nodeId: string) => void + /** All node ids on the path of an update. Edges with both endpoints in this set animate. */ + connectionPathNodeIds: Set + /** Call when a path update starts for this node. Animation runs at least CONNECTION_PATH_UPDATE_MIN_MS. */ + startConnectionPathUpdate: (nodeId: string) => void + /** Call when a path update ends for this node. If min duration not reached, animation continues until then. */ + endConnectionPathUpdate: (nodeId: string) => void } const FlowContext = React.createContext(null) export default FlowContext + +/** + * Returns this node's role in the current path update for styling (pushing vs receiving). + * Use with BaseNode's connectionPathRole prop or data-path-role for CSS. + */ +export function useConnectionPathRole(nodeId: string | undefined): ConnectionPathRole | null { + const ctx = React.useContext(FlowContext) + return useMemo(() => { + if (!nodeId) return null + const triggers = ctx?.connectionPathTriggerNodeIds + const updating = ctx?.connectionPathUpdatingNodeIds + const path = ctx?.connectionPathNodeIds + if (!path?.has(nodeId)) return null + if (triggers?.includes(nodeId)) return 'trigger' + if (updating?.includes(nodeId)) return 'updating' + return 'on-path' + }, [nodeId, ctx?.connectionPathTriggerNodeIds, ctx?.connectionPathUpdatingNodeIds, ctx?.connectionPathNodeIds]) +} diff --git a/frontend/src/lib/graphPath.ts b/frontend/src/lib/graphPath.ts new file mode 100644 index 0000000..6ebffe3 --- /dev/null +++ b/frontend/src/lib/graphPath.ts @@ -0,0 +1,67 @@ +/** + * Graph path utilities: compute which nodes/edges are "on the path" of an update. + * Used to show connection ant trail only along the full chain (upstream → updating → downstream). + * Works with any node types; any node can signal it is updating via startConnectionPathUpdate(id). + */ + +export type GraphEdge = { source: string; target: string } + +/** Nodes reachable from seedIds by following edges forward (source → target). */ +export function getDownstreamNodeIds(edges: GraphEdge[], seedIds: string[]): Set { + const out = new Set(seedIds) + let added = true + while (added) { + added = false + for (const e of edges) { + if (out.has(e.source) && !out.has(e.target)) { + out.add(e.target) + added = true + } + } + } + return out +} + +/** Nodes that can reach any seed by following edges backward (target → source). */ +export function getUpstreamNodeIds(edges: GraphEdge[], seedIds: string[]): Set { + const out = new Set(seedIds) + let added = true + while (added) { + added = false + for (const e of edges) { + if (out.has(e.target) && !out.has(e.source)) { + out.add(e.source) + added = true + } + } + } + return out +} + +/** + * All node ids that lie on the path of an update. + * - If triggerNodeIds is non-empty: path = nodes that are both downstream of a trigger and + * upstream of an updating node (only the chain that actually triggered the update). + * - Otherwise: path = upstream ∪ updating ∪ downstream of updating (legacy full upstream/downstream). + * An edge should show the update animation iff both its source and target are in this set. + */ +export function getPathNodeIds( + edges: GraphEdge[], + updatingNodeIds: string[], + triggerNodeIds?: string[] +): Set { + if (updatingNodeIds.length === 0) return new Set() + const upstream = getUpstreamNodeIds(edges, updatingNodeIds) + if (triggerNodeIds != null && triggerNodeIds.length > 0) { + const downstreamOfTrigger = getDownstreamNodeIds(edges, triggerNodeIds) + const path = new Set() + upstream.forEach((id) => { + if (downstreamOfTrigger.has(id)) path.add(id) + }) + return path + } + const downstream = getDownstreamNodeIds(edges, updatingNodeIds) + const path = new Set(upstream) + downstream.forEach((id) => path.add(id)) + return path +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 5dc2a2a..c2289ca 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -24,9 +24,11 @@ body { .react-flow__pane { cursor: crosshair; } + .react-flow.react-flow--panning .react-flow__pane { cursor: grabbing; } + .react-flow.react-flow--selecting .react-flow__pane { cursor: crosshair; } @@ -133,26 +135,43 @@ body { min-height: min-content; } -/* Animated React Flow edges: thicker stroke + path animation */ +/* Animated React Flow edges: ant trail always on; color shows updating path */ .react-flow__edge path.animated-edge-path, .animated-edge-path { stroke-width: 4; - stroke: hsl(var(--foreground) / 0.6); + stroke: hsl(var(--foreground) / 0.5); fill: none; stroke-dasharray: 8 6; - animation: edge-flow 0.6s linear infinite; + stroke-dashoffset: 0; + animation: edge-flow 1.2s linear infinite; + will-change: stroke-dashoffset; + transform: translateZ(0); + backface-visibility: hidden; + transition: stroke 0.4s ease-out; +} + +.react-flow__edge path.animated-edge-path.animated-edge-path--updating, +.animated-edge-path.animated-edge-path--updating { + stroke: hsl(var(--primary)); } @keyframes edge-flow { - from { + 0% { stroke-dashoffset: 14; } - - to { + 100% { stroke-dashoffset: 0; } } +/* Connection path roles: pushing (trigger) vs receiving (updating) — classes on BaseNode root */ +.connection-path-trigger { + box-shadow: inset 3px 0 0 hsl(var(--primary)); +} +.connection-path-updating { + box-shadow: inset 0 0 0 2px hsl(var(--primary) / 0.4); +} + /* NodeResizer: snappy drag; no border/background on handles; keep resize cursors. */ .react-flow__resize-control { touch-action: none; @@ -174,14 +193,37 @@ body { } /* Resize cursor per position (keep cursor style change on hover). */ -.react-flow__resize-control.top.left { cursor: nw-resize; } -.react-flow__resize-control.top.right { cursor: ne-resize; } -.react-flow__resize-control.bottom.left { cursor: sw-resize; } -.react-flow__resize-control.bottom.right { cursor: se-resize; } -.react-flow__resize-control.top { cursor: n-resize; } -.react-flow__resize-control.right { cursor: e-resize; } -.react-flow__resize-control.bottom { cursor: s-resize; } -.react-flow__resize-control.left { cursor: w-resize; } +.react-flow__resize-control.top.left { + cursor: nw-resize; +} + +.react-flow__resize-control.top.right { + cursor: ne-resize; +} + +.react-flow__resize-control.bottom.left { + cursor: sw-resize; +} + +.react-flow__resize-control.bottom.right { + cursor: se-resize; +} + +.react-flow__resize-control.top { + cursor: n-resize; +} + +.react-flow__resize-control.right { + cursor: e-resize; +} + +.react-flow__resize-control.bottom { + cursor: s-resize; +} + +.react-flow__resize-control.left { + cursor: w-resize; +} /* Small helper for monospace pre output */ pre {