Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/agentflow/src/core/types/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ export interface NodeData extends NodeDefinitionBase {
id: string
inputParams?: InputParam[] // Parameter definitions
inputs?: Record<string, unknown> // Actual values entered by users
disabled?: boolean
disabledBy?: string
// Status properties
status?: ExecutionStatus
error?: string
Expand Down
97 changes: 97 additions & 0 deletions packages/agentflow/src/core/utils/disabledNodes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import type { FlowEdge, FlowNode } from '@/core/types'

export const isNodeExplicitlyDisabled = (node: FlowNode): boolean => {
const disabled = node?.data?.disabled
return disabled === true || String(disabled) === 'true'
}

export const recalculateDisabledNodes = (nodes: FlowNode[], edges: FlowEdge[]): FlowNode[] => {
const explicitlyDisabledNodeIds = new Set<string>(
nodes
.filter((node) => {
const disabled = node.data?.disabled === true || String(node.data?.disabled) === 'true'
const disabledBy = node.data?.disabledBy
return disabled && !disabledBy
})
.map((node) => node.id)
)

const outgoingEdges = new Map<string, string[]>()
for (const edge of edges) {
const isToolEdge =
edge.targetHandle &&
(edge.targetHandle.includes('-input-tools-') ||
edge.targetHandle.split('-input-')[1]?.startsWith('tools-') ||
edge.targetHandle === 'tools' ||
edge.targetHandle.startsWith('tools-'))
const isModelEdge =
edge.targetHandle &&
(edge.targetHandle.includes('-input-model-') ||
edge.targetHandle.split('-input-')[1]?.startsWith('model-') ||
edge.targetHandle === 'model' ||
edge.targetHandle.startsWith('model-'))
if (isToolEdge || isModelEdge) continue

const targets = outgoingEdges.get(edge.source) || []
targets.push(edge.target)
outgoingEdges.set(edge.source, targets)
}

const disabledByMap = new Map<string, string>()

for (const rootId of explicitlyDisabledNodeIds) {
const queue = [rootId]
const visited = new Set<string>([rootId])

while (queue.length > 0) {
const currentId = queue.shift()!
const targets = outgoingEdges.get(currentId) || []
for (const targetId of targets) {
if (visited.has(targetId)) continue
visited.add(targetId)

if (explicitlyDisabledNodeIds.has(targetId)) continue

if (!disabledByMap.has(targetId)) {
disabledByMap.set(targetId, rootId)
}
queue.push(targetId)
}
}
}

return nodes.map((node) => {
const isExplicit = explicitlyDisabledNodeIds.has(node.id)
if (isExplicit) {
const { disabledBy, ...nextData } = node.data

Check warning on line 66 in packages/agentflow/src/core/utils/disabledNodes.ts

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 20.20.2)

'disabledBy' is assigned a value but never used. Allowed unused vars must match /^_/u
return {
...node,
data: {
...nextData,
disabled: true
}
}
}

const disabledBy = disabledByMap.get(node.id)
if (disabledBy) {
return {
...node,
data: {
...node.data,
disabled: true,
disabledBy
}
}
}

const { disabled, disabledBy: _, ...nextData } = node.data

Check warning on line 88 in packages/agentflow/src/core/utils/disabledNodes.ts

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 20.20.2)

'disabled' is assigned a value but never used. Allowed unused vars must match /^_/u
return {
...node,
data: {
...nextData,
disabled: false
}
}
})
}
Comment thread
udaykumar-dhokia marked this conversation as resolved.
1 change: 1 addition & 0 deletions packages/agentflow/src/core/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,5 @@ export { buildDynamicOutputAnchors, parseOutputHandleIndex } from './dynamicOutp
export { getDefinedStateKeys, getUpstreamNodes } from './variableUtils'

// Node version detection and upgrade utilities
export { isNodeExplicitlyDisabled, recalculateDisabledNodes } from './disabledNodes'
export { getNodeVersionWarning, isNodeOutdated, upgradeNodeData } from './nodeVersionUtils'
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,14 @@ jest.mock('../styled', () => ({

const mockDeleteNode = jest.fn()
const mockDuplicateNode = jest.fn()
const mockToggleNodeDisabled = jest.fn()
const mockOpenNodeEditor = jest.fn()

jest.mock('@/infrastructure/store', () => ({
useAgentflowContext: () => ({
deleteNode: mockDeleteNode,
duplicateNode: mockDuplicateNode
duplicateNode: mockDuplicateNode,
toggleNodeDisabled: mockToggleNodeDisabled
}),
useConfigContext: () => ({ isDarkMode: false })
}))
Expand All @@ -39,7 +41,8 @@ jest.mock('@mui/material/styles', () => ({
palette: {
primary: { main: '#1976d2' },
error: { main: '#d32f2f' },
info: { main: '#0288d1' }
info: { main: '#0288d1' },
warning: { main: '#ed6c02' }
}
})
}))
Expand All @@ -57,6 +60,8 @@ jest.mock('@tabler/icons-react', () => ({
IconCopy: () => <svg />,
IconEdit: () => <svg />,
IconInfoCircle: () => <svg />,
IconPlayerPause: () => <svg />,
IconPlayerPlay: () => <svg />,
IconTrash: () => <svg />
}))

Expand Down Expand Up @@ -97,23 +102,31 @@ describe('NodeToolbarActions', () => {
renderToolbar({ nodeName: 'llmAgentflow' })
expect(screen.getByTitle('Duplicate')).toBeInTheDocument()
expect(screen.getByTitle('Edit')).toBeInTheDocument()
expect(screen.getByTitle('Disable')).toBeInTheDocument()
expect(screen.getByTitle('Delete')).toBeInTheDocument()
})

it('hides Duplicate for startAgentflow', () => {
renderToolbar({ nodeName: 'startAgentflow' })
expect(screen.queryByTitle('Duplicate')).not.toBeInTheDocument()
expect(screen.getByTitle('Edit')).toBeInTheDocument()
expect(screen.getByTitle('Disable')).toBeInTheDocument()
expect(screen.getByTitle('Delete')).toBeInTheDocument()
})

it('hides Edit for stickyNoteAgentflow', () => {
renderToolbar({ nodeName: 'stickyNoteAgentflow' })
expect(screen.queryByTitle('Edit')).not.toBeInTheDocument()
expect(screen.queryByTitle('Disable')).not.toBeInTheDocument()
expect(screen.getByTitle('Duplicate')).toBeInTheDocument()
expect(screen.getByTitle('Delete')).toBeInTheDocument()
})

it('renders Enable when node is disabled', () => {
renderToolbar({ disabled: true })
expect(screen.getByTitle('Enable')).toBeInTheDocument()
})

it('renders Info button when onInfoClick is provided', () => {
renderToolbar({ onInfoClick: jest.fn() })
expect(screen.getByTitle('Info')).toBeInTheDocument()
Expand All @@ -140,6 +153,13 @@ describe('NodeToolbarActions', () => {
expect(mockDeleteNode).toHaveBeenCalledWith('node-1')
})

it('calls toggleNodeDisabled when Disable is clicked', async () => {
const user = userEvent.setup()
renderToolbar()
await user.click(screen.getByTitle('Disable'))
expect(mockToggleNodeDisabled).toHaveBeenCalledWith('node-1')
})

it('calls openNodeEditor when Edit is clicked', async () => {
const user = userEvent.setup()
renderToolbar()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Position } from 'reactflow'

import { ButtonGroup, IconButton } from '@mui/material'
import { useTheme } from '@mui/material/styles'
import { IconCopy, IconEdit, IconInfoCircle, IconTrash } from '@tabler/icons-react'
import { IconCopy, IconEdit, IconInfoCircle, IconPlayerPause, IconPlayerPlay, IconTrash } from '@tabler/icons-react'

import { useAgentflowContext, useConfigContext } from '@/infrastructure/store'

Expand All @@ -14,16 +14,21 @@ export interface NodeToolbarActionsProps {
nodeId: string
nodeName: string
isVisible: boolean
disabled?: boolean
disabledBy?: string
onInfoClick?: () => void
}

/**
* Toolbar with action buttons for a node (duplicate, delete, info)
*/
function NodeToolbarActionsComponent({ nodeId, nodeName, isVisible, onInfoClick }: NodeToolbarActionsProps) {
function NodeToolbarActionsComponent({ nodeId, nodeName, isVisible, disabled, disabledBy, onInfoClick }: NodeToolbarActionsProps) {
const theme = useTheme()
const { isDarkMode } = useConfigContext()
const { deleteNode, duplicateNode } = useAgentflowContext()
const { deleteNode, duplicateNode, toggleNodeDisabled, state } = useAgentflowContext()
const nodes = state?.nodes || []
const disabledByNode = disabledBy ? nodes.find((n) => n.id === disabledBy) : null
const disabledByName = disabledByNode ? disabledByNode.data?.label || disabledByNode.data?.name : disabledBy
const { openNodeEditor } = useOpenNodeEditor()

const handleEditClick = () => {
Expand Down Expand Up @@ -62,6 +67,24 @@ function NodeToolbarActionsComponent({ nodeId, nodeName, isVisible, onInfoClick
<IconEdit size={20} />
</IconButton>
)}
{nodeName !== 'stickyNoteAgentflow' && (
<IconButton
size='small'
title={disabledBy ? `Disabled by upstream node: ${disabledByName}` : disabled ? 'Enable' : 'Disable'}
onClick={() => {
if (!disabledBy) {
toggleNodeDisabled(nodeId)
}
}}
disabled={!!disabledBy}
sx={{
color: isDarkMode ? 'white' : 'inherit',
'&:hover': { color: theme.palette.warning.main }
}}
>
{disabled ? <IconPlayerPlay size={20} /> : <IconPlayerPause size={20} />}
</IconButton>
)}
<IconButton
size='small'
title='Delete'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,14 +80,22 @@ function AgentFlowNodeComponent({ data }: AgentFlowNodeProps) {
nodeId={data.id}
nodeName={data.name}
isVisible={data.selected || isHovered}
disabled={data.disabled}
disabledBy={data.disabledBy}
onInfoClick={() => setShowInfoDialog(true)}
/>

<CardWrapper
content={false}
sx={{
borderColor: hasValidationErrors ? tokens.colors.border.validation : stateColor,
borderWidth: hasValidationErrors ? '2px' : '1px',
borderColor: data.disabled
? tokens.colors.semantic.warning
: hasValidationErrors
? tokens.colors.border.validation
: stateColor,
borderWidth: data.disabled || hasValidationErrors ? '2px' : '1px',
borderStyle: data.disabled ? 'dashed' : 'solid',
opacity: data.disabled ? 0.48 : 1,
boxShadow: data.selected ? `0 0 0 1px ${stateColor} !important` : 'none',
minHeight,
height: 'auto',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,14 +111,18 @@ function IterationNodeComponent({ data }: IterationNodeProps) {
nodeId={data.id}
nodeName={data.name}
isVisible={data.selected || isHovered}
disabled={data.disabled}
disabledBy={data.disabledBy}
onInfoClick={() => setShowInfoDialog(true)}
/>
<NodeResizer minWidth={300} minHeight={minHeight} onResizeEnd={onResizeEnd} />
<CardWrapper
content={false}
sx={{
borderColor: stateColor,
borderWidth: '1px',
borderColor: data.disabled ? theme.palette.warning.main : stateColor,
borderWidth: data.disabled ? '2px' : '1px',
borderStyle: data.disabled ? 'dashed' : 'solid',
opacity: data.disabled ? 0.48 : 1,
boxShadow: data.selected ? `0 0 0 1px ${stateColor} !important` : 'none',
minHeight,
minWidth: 300,
Expand Down
48 changes: 41 additions & 7 deletions packages/agentflow/src/infrastructure/store/AgentflowContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
NodeData,
NodeDataSchema
} from '@/core/types'
import { getDefinedStateKeys, getUniqueNodeId, isNodeOutdated, upgradeNodeData } from '@/core/utils'
import { getDefinedStateKeys, getUniqueNodeId, isNodeOutdated, recalculateDisabledNodes, upgradeNodeData } from '@/core/utils'

import { agentflowReducer, initialState, normalizeNodes } from './agentflowReducer'

Expand Down Expand Up @@ -70,6 +70,7 @@
// Node operations
deleteNode: (nodeId: string) => void
duplicateNode: (nodeId: string, distance?: number) => void
toggleNodeDisabled: (nodeId: string) => void
updateNodeData: (nodeId: string, data: Partial<FlowNode['data']>, edges?: FlowEdge[]) => void

// Edge operations
Expand Down Expand Up @@ -198,14 +199,15 @@
}
collectDescendants(nodeId)

const newNodes = state.nodes.filter((node) => !toDelete.has(node.id))
const newEdges = state.edges.filter((edge) => !toDelete.has(edge.source) && !toDelete.has(edge.target))
syncStateUpdate({ nodes: newNodes, edges: newEdges })
const remainingNodes = state.nodes.filter((node) => !toDelete.has(node.id))
const remainingEdges = state.edges.filter((edge) => !toDelete.has(edge.source) && !toDelete.has(edge.target))
const recalculatedNodes = recalculateDisabledNodes(remainingNodes, remainingEdges)
syncStateUpdate({ nodes: recalculatedNodes, edges: remainingEdges })

// Notify parent of flow change so the deletion is persisted
if (onFlowChangeRef.current) {
const viewport = state.reactFlowInstance?.getViewport() || { x: 0, y: 0, zoom: 1 }
onFlowChangeRef.current({ nodes: newNodes, edges: newEdges, viewport })
onFlowChangeRef.current({ nodes: recalculatedNodes, edges: remainingEdges, viewport })
}
},
[state.nodes, state.edges, state.reactFlowInstance, syncStateUpdate]
Expand Down Expand Up @@ -294,16 +296,47 @@
[state.nodes, state.edges, state.reactFlowInstance, syncStateUpdate]
)

const toggleNodeDisabled = useCallback(
(nodeId: string) => {
const targetNode = state.nodes.find((node) => node.id === nodeId)
const shouldDisable = !(targetNode?.data?.disabled === true || String(targetNode?.data?.disabled) === 'true')

const nextNodes = state.nodes.map((node) => {
if (node.id === nodeId) {
const { disabledBy, ...nextData } = node.data

Check warning on line 306 in packages/agentflow/src/infrastructure/store/AgentflowContext.tsx

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 20.20.2)

'disabledBy' is assigned a value but never used. Allowed unused vars must match /^_/u
return {
...node,
data: {
...nextData,
disabled: shouldDisable
}
}
}
return node
})

const recalculatedNodes = recalculateDisabledNodes(nextNodes, state.edges)
syncStateUpdate({ nodes: recalculatedNodes })

if (onFlowChangeRef.current) {
const viewport = state.reactFlowInstance?.getViewport() || { x: 0, y: 0, zoom: 1 }
onFlowChangeRef.current({ nodes: recalculatedNodes, edges: state.edges, viewport })
}
},
[state.nodes, state.edges, state.reactFlowInstance, syncStateUpdate]
)

// Edge operations
const deleteEdge = useCallback(
(edgeId: string) => {
const newEdges = state.edges.filter((edge) => edge.id !== edgeId)
syncStateUpdate({ edges: newEdges })
const recalculatedNodes = recalculateDisabledNodes(state.nodes, newEdges)
syncStateUpdate({ nodes: recalculatedNodes, edges: newEdges })

// Notify parent of flow change so the deletion is persisted
if (onFlowChangeRef.current) {
const viewport = state.reactFlowInstance?.getViewport() || { x: 0, y: 0, zoom: 1 }
onFlowChangeRef.current({ nodes: state.nodes, edges: newEdges, viewport })
onFlowChangeRef.current({ nodes: recalculatedNodes, edges: newEdges, viewport })
}
},
[state.nodes, state.edges, state.reactFlowInstance, syncStateUpdate]
Expand Down Expand Up @@ -415,6 +448,7 @@
setReactFlowInstance,
deleteNode,
duplicateNode,
toggleNodeDisabled,
updateNodeData,
deleteEdge,
openEditDialog,
Expand Down
Loading
Loading