diff --git a/frontend/src/app/canvas/CanvasPage.tsx b/frontend/src/app/canvas/CanvasPage.tsx index 5c8a826..c07896a 100644 --- a/frontend/src/app/canvas/CanvasPage.tsx +++ b/frontend/src/app/canvas/CanvasPage.tsx @@ -274,6 +274,7 @@ export function CanvasPage({ projectId }: CanvasPageProps) { const pathUpdateEndTimeoutRef = useRef | null>(null) const [connectionPathPausedNodeIds, setConnectionPathPausedNodeIds] = React.useState([]) + const [connectionPathErrorNodeIds, setConnectionPathErrorNodeIds] = React.useState([]) const connectionPathPausedNodeIdsRef = useRef([]) connectionPathPausedNodeIdsRef.current = connectionPathPausedNodeIds @@ -586,6 +587,14 @@ export function CanvasPage({ projectId }: CanvasPageProps) { setConnectionPathPausedNodeIds((prev) => prev.filter((id) => id !== nodeId)) }, []) + const addConnectionPathError = useCallback((nodeId: string) => { + setConnectionPathErrorNodeIds((prev) => (prev.includes(nodeId) ? prev : [...prev, nodeId])) + }, []) + + const removeConnectionPathError = useCallback((nodeId: string) => { + setConnectionPathErrorNodeIds((prev) => prev.filter((id) => id !== nodeId)) + }, []) + const flowContextValue = useMemo( () => ({ nodes, @@ -609,6 +618,9 @@ export function CanvasPage({ projectId }: CanvasPageProps) { connectionPathPausedNodeIds, addConnectionPathPausedNode, removeConnectionPathPausedNode, + connectionPathErrorNodeIds, + addConnectionPathError, + removeConnectionPathError, startConnectionPathUpdate, endConnectionPathUpdate, }), @@ -634,6 +646,9 @@ export function CanvasPage({ projectId }: CanvasPageProps) { connectionPathPausedNodeIds, addConnectionPathPausedNode, removeConnectionPathPausedNode, + connectionPathErrorNodeIds, + addConnectionPathError, + removeConnectionPathError, startConnectionPathUpdate, endConnectionPathUpdate, ] diff --git a/frontend/src/components/base/AnimatedEdge.tsx b/frontend/src/components/base/AnimatedEdge.tsx index e8bf602..0117ee3 100644 --- a/frontend/src/components/base/AnimatedEdge.tsx +++ b/frontend/src/components/base/AnimatedEdge.tsx @@ -5,6 +5,7 @@ import { type EdgeProps, } from '@xyflow/react' import FlowContext from '../../lib/flowContext' +import { getConnectionStatus, CONNECTION_STATUS_CLASS } from '../../lib/connectionStatus' import { getConnectionLabelForTarget } from '../../lib/nodeRegistry' const EDGE_STROKE_WIDTH = 2 @@ -30,6 +31,10 @@ export function AnimatedEdge({ const pathNodeIds = ctx?.connectionPathNodeIds ?? EMPTY_PATH_NODE_IDS const pausedSegmentNodeIds = ctx?.connectionPathPausedSegmentNodeIds ?? EMPTY_PATH_NODE_IDS const activeSegmentNodeIds = ctx?.connectionPathActiveSegmentNodeIds ?? EMPTY_PATH_NODE_IDS + const errorTargetNodeIds = useMemo( + () => new Set(ctx?.connectionPathErrorNodeIds ?? []), + [ctx?.connectionPathErrorNodeIds] + ) const targetNode = useMemo(() => nodes.find((n: any) => n.id === target), [nodes, target]) const derivedLabel = useMemo( () => (targetNode?.type != null ? getConnectionLabelForTarget(targetNode.type) : undefined), @@ -37,10 +42,26 @@ export function AnimatedEdge({ ) const label = labelProp ?? derivedLabel - const isOnPausedSegment = - pathNodeIds.has(source) && pathNodeIds.has(target) && pausedSegmentNodeIds.has(source) && pausedSegmentNodeIds.has(target) - const isOnUpdatingPath = - pathNodeIds.has(source) && pathNodeIds.has(target) && activeSegmentNodeIds.has(source) && activeSegmentNodeIds.has(target) + const connectionStatus = useMemo( + () => + getConnectionStatus({ + source, + target, + pathNodeIds, + pausedSegmentNodeIds, + activeSegmentNodeIds, + errorTargetNodeIds, + }), + [ + source, + target, + pathNodeIds, + pausedSegmentNodeIds, + activeSegmentNodeIds, + errorTargetNodeIds, + ] + ) + const statusClass = CONNECTION_STATUS_CLASS[connectionStatus] const [edgePath, edgeLabelX, edgeLabelY] = getBezierPath({ sourceX, @@ -98,7 +119,7 @@ export function AnimatedEdge({ strokeWidth: EDGE_STROKE_WIDTH, ...style, }} - className={`animated-edge-path${isOnPausedSegment ? ' animated-edge-path--paused' : isOnUpdatingPath ? ' animated-edge-path--updating' : ''}`} + className={`animated-edge-path${statusClass ? ` ${statusClass}` : ''}`} interactionWidth={interactionWidth} /> {label != null && ( diff --git a/frontend/src/components/nodes/RenderingNode.tsx b/frontend/src/components/nodes/RenderingNode.tsx index 197f37d..469f31f 100644 --- a/frontend/src/components/nodes/RenderingNode.tsx +++ b/frontend/src/components/nodes/RenderingNode.tsx @@ -52,6 +52,8 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) { const setFullscreenNodeId = flowContext?.setFullscreenNodeId const startConnectionPathUpdate = flowContext?.startConnectionPathUpdate const endConnectionPathUpdate = flowContext?.endConnectionPathUpdate + const addConnectionPathError = flowContext?.addConnectionPathError + const removeConnectionPathError = flowContext?.removeConnectionPathError const supportsFullscreen = getNodeType('render')?.supportsFullscreen const [renderedContent, setRenderedContent] = useState(null) const [resolvedContent, setResolvedContent] = useState(null) @@ -78,6 +80,15 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) { const sourceContent = srcNode?.type === 'config' ? getConfigContent((srcNode.data ?? undefined) as Record | undefined) : isAgentSource ? agentOutputMarkdown : '' const srcData = srcNode?.data ?? {} + useEffect(() => { + if (!addConnectionPathError || !removeConnectionPathError) return + if (error != null) { + addConnectionPathError(id) + return () => removeConnectionPathError(id) + } + removeConnectionPathError(id) + }, [id, error, addConnectionPathError, removeConnectionPathError]) + /** Set of node IDs that can affect this render node (configs in the chain + variables/functions feeding them) */ const connectedNodeIds = useMemo(() => { const out = new Set() diff --git a/frontend/src/lib/connectionStatus.ts b/frontend/src/lib/connectionStatus.ts new file mode 100644 index 0000000..e983ede --- /dev/null +++ b/frontend/src/lib/connectionStatus.ts @@ -0,0 +1,48 @@ +/** + * Connection status: visual state of an edge (color/class). + * Priority when multiple apply: error > paused > updating > default. + * Nodes report state via FlowContext (e.g. addConnectionPathError); edges derive status here. + */ + +export type ConnectionStatus = 'default' | 'updating' | 'paused' | 'error' + +export type ConnectionStatusInputs = { + source: string + target: string + pathNodeIds: Set + pausedSegmentNodeIds: Set + activeSegmentNodeIds: Set + errorTargetNodeIds: Set +} + +/** + * Compute the single connection status for an edge (priority: error > paused > updating > default). + * Use in edge components; add new statuses by extending the type and adding a branch here. + */ +export function getConnectionStatus(inputs: ConnectionStatusInputs): ConnectionStatus { + const { target, pathNodeIds, pausedSegmentNodeIds, activeSegmentNodeIds, errorTargetNodeIds, source } = inputs + if (errorTargetNodeIds.has(target)) return 'error' + if ( + pathNodeIds.has(source) && + pathNodeIds.has(target) && + pausedSegmentNodeIds.has(source) && + pausedSegmentNodeIds.has(target) + ) + return 'paused' + if ( + pathNodeIds.has(source) && + pathNodeIds.has(target) && + activeSegmentNodeIds.has(source) && + activeSegmentNodeIds.has(target) + ) + return 'updating' + return 'default' +} + +/** CSS class suffix for each status (animated-edge-path--{status}). */ +export const CONNECTION_STATUS_CLASS: Record = { + default: '', + updating: 'animated-edge-path--updating', + paused: 'animated-edge-path--paused', + error: 'animated-edge-path--error', +} diff --git a/frontend/src/lib/flowContext.tsx b/frontend/src/lib/flowContext.tsx index f607adf..d358c87 100644 --- a/frontend/src/lib/flowContext.tsx +++ b/frontend/src/lib/flowContext.tsx @@ -46,6 +46,12 @@ export type FlowContextValue = { addConnectionPathPausedNode: (nodeId: string) => void /** Remove this node from paused. */ removeConnectionPathPausedNode: (nodeId: string) => void + /** Node ids that have an error (e.g. Render node). Incoming edges show error status (red). */ + connectionPathErrorNodeIds: string[] + /** Call when this node has an error; remove when error is cleared. */ + addConnectionPathError: (nodeId: string) => void + /** Call when this node's error is cleared. */ + removeConnectionPathError: (nodeId: string) => void /** 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. */ diff --git a/frontend/src/styles.css b/frontend/src/styles.css index e6d234c..a716086 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -162,6 +162,12 @@ body { transition: none; } +.react-flow__edge path.animated-edge-path.animated-edge-path--error, +.animated-edge-path.animated-edge-path--error { + stroke: hsl(0 70% 50%); + transition: none; +} + @keyframes edge-flow { 0% { stroke-dashoffset: 14;