diff --git a/eslint-suppressions.json b/eslint-suppressions.json
index f1a5e6ce87..dcb58d4b57 100644
--- a/eslint-suppressions.json
+++ b/eslint-suppressions.json
@@ -4354,11 +4354,6 @@
"count": 1
}
},
- "web/app/components/workflow/nodes/_base/components/node-handle.tsx": {
- "react/set-state-in-effect": {
- "count": 1
- }
- },
"web/app/components/workflow/nodes/_base/components/option-card.tsx": {
"no-restricted-imports": {
"count": 1
diff --git a/web/app/components/workflow/nodes/_base/components/__tests__/node-handle.spec.tsx b/web/app/components/workflow/nodes/_base/components/__tests__/node-handle.spec.tsx
new file mode 100644
index 0000000000..772814f0f3
--- /dev/null
+++ b/web/app/components/workflow/nodes/_base/components/__tests__/node-handle.spec.tsx
@@ -0,0 +1,373 @@
+import type { ReactNode } from 'react'
+import type { CommonNodeType } from '@/app/components/workflow/types'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { BlockEnum, NodeRunningStatus } from '@/app/components/workflow/types'
+import { NodeSourceHandle, NodeTargetHandle } from '../node-handle'
+
+type MockHooksState = {
+ availablePrevBlocks: BlockEnum[]
+ availableNextBlocks: BlockEnum[]
+ isChatMode: boolean
+ isReadOnly: boolean
+}
+
+type MockStoreState = {
+ shouldAutoOpenStartNodeSelector: boolean
+ setShouldAutoOpenStartNodeSelector?: (open: boolean) => void
+ setHasSelectedStartNode?: (selected: boolean) => void
+}
+
+const {
+ mockHandleNodeAdd,
+ mockSetShouldAutoOpenStartNodeSelector,
+ mockSetHasSelectedStartNode,
+ mockWorkflowStoreSetState,
+ mockHooksState,
+ mockStoreState,
+} = vi.hoisted(() => {
+ const mockHooksState: MockHooksState = {
+ availablePrevBlocks: [],
+ availableNextBlocks: [],
+ isChatMode: false,
+ isReadOnly: false,
+ }
+ const mockStoreState: MockStoreState = {
+ shouldAutoOpenStartNodeSelector: false,
+ setShouldAutoOpenStartNodeSelector: undefined,
+ setHasSelectedStartNode: undefined,
+ }
+
+ return {
+ mockHandleNodeAdd: vi.fn(),
+ mockSetShouldAutoOpenStartNodeSelector: vi.fn(),
+ mockSetHasSelectedStartNode: vi.fn(),
+ mockWorkflowStoreSetState: vi.fn(),
+ mockHooksState,
+ mockStoreState,
+ }
+})
+
+type HandleProps = {
+ id?: string
+ className?: string
+ children?: ReactNode
+ onClick?: () => void
+}
+
+type BlockSelectorProps = {
+ open?: boolean
+ onOpenChange?: (open: boolean) => void
+ onSelect?: (type: BlockEnum, pluginDefaultValue?: { pluginId: string }) => void
+ triggerClassName?: (open: boolean) => string
+}
+
+vi.mock('reactflow', () => ({
+ Handle: ({ id, className, children, onClick }: HandleProps) => (
+
+ {children}
+
+ ),
+ Position: {
+ Left: 'left',
+ Right: 'right',
+ },
+}))
+
+vi.mock('@/app/components/workflow/block-selector', () => ({
+ default: ({ open = false, onOpenChange, onSelect, triggerClassName }: BlockSelectorProps) => (
+
+
+
+
+ ),
+}))
+
+vi.mock('@/app/components/workflow/hooks', () => ({
+ useAvailableBlocks: () => ({
+ availablePrevBlocks: mockHooksState.availablePrevBlocks,
+ availableNextBlocks: mockHooksState.availableNextBlocks,
+ }),
+ useIsChatMode: () => mockHooksState.isChatMode,
+ useNodesInteractions: () => ({
+ handleNodeAdd: mockHandleNodeAdd,
+ }),
+ useNodesReadOnly: () => ({
+ getNodesReadOnly: () => mockHooksState.isReadOnly,
+ }),
+}))
+
+vi.mock('@/app/components/workflow/store', () => ({
+ useStore: (selector: (state: MockStoreState) => T) => selector(mockStoreState),
+ useWorkflowStore: () => ({
+ setState: mockWorkflowStoreSetState,
+ }),
+}))
+
+const createNodeData = (overrides: Partial = {}): CommonNodeType => ({
+ type: BlockEnum.Code,
+ title: 'Node',
+ desc: '',
+ selected: false,
+ ...overrides,
+})
+
+const getAddNodeButton = () => screen.getByRole('button', { name: 'add-node' })
+const queryAddNodeButton = () => screen.queryByRole('button', { name: 'add-node' })
+const getSelectNodeButton = () => screen.getByRole('button', { name: 'select-node' })
+
+const renderTargetHandle = (dataOverrides: Partial = {}) => {
+ return render(
+ ,
+ )
+}
+
+const renderSourceHandle = (
+ dataOverrides: Partial = {},
+ propsOverrides: Partial> = {},
+) => {
+ return render(
+ ,
+ )
+}
+
+describe('node-handle', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+
+ mockHooksState.availablePrevBlocks = [BlockEnum.Code]
+ mockHooksState.availableNextBlocks = [BlockEnum.Code]
+ mockHooksState.isChatMode = false
+ mockHooksState.isReadOnly = false
+
+ mockStoreState.shouldAutoOpenStartNodeSelector = false
+ mockStoreState.setShouldAutoOpenStartNodeSelector = mockSetShouldAutoOpenStartNodeSelector
+ mockStoreState.setHasSelectedStartNode = mockSetHasSelectedStartNode
+ })
+
+ // Target-side tests cover selector visibility, connection locking, and status rendering.
+ describe('NodeTargetHandle', () => {
+ it('should toggle the target add trigger and select the next node', () => {
+ renderTargetHandle()
+
+ const handle = screen.getByTestId('handle-target-handle')
+ const addNodeButton = getAddNodeButton()
+
+ expect(addNodeButton).toHaveClass('custom-selector')
+ expect(addNodeButton).toHaveClass('opacity-0')
+ expect(addNodeButton).toHaveClass('pointer-events-none')
+
+ fireEvent.click(addNodeButton)
+
+ expect(addNodeButton).toHaveClass('opacity-100')
+ expect(addNodeButton).toHaveClass('pointer-events-auto')
+
+ fireEvent.click(handle)
+
+ expect(addNodeButton).toHaveClass('opacity-0')
+
+ fireEvent.click(getSelectNodeButton())
+
+ expect(mockHandleNodeAdd).toHaveBeenCalledWith(
+ {
+ nodeType: BlockEnum.Answer,
+ pluginDefaultValue: { pluginId: 'plugin-1' },
+ },
+ {
+ nextNodeId: 'target-node',
+ nextNodeTargetHandle: 'target-handle',
+ },
+ )
+ })
+
+ it('should not render the target add trigger when the handle is already connected', () => {
+ renderTargetHandle({
+ _connectedTargetHandleIds: ['target-handle'],
+ })
+
+ fireEvent.click(screen.getByTestId('handle-target-handle'))
+
+ expect(queryAddNodeButton()).not.toBeInTheDocument()
+ })
+
+ it('should hide the target handle for workflow entry nodes', () => {
+ renderTargetHandle({ type: BlockEnum.TriggerPlugin })
+
+ expect(screen.getByTestId('handle-target-handle')).toHaveClass('opacity-0')
+ })
+
+ it('should keep the target add trigger visible when the node is selected', () => {
+ renderTargetHandle({
+ selected: true,
+ })
+
+ expect(getAddNodeButton()).toHaveClass('opacity-100')
+ expect(getAddNodeButton()).toHaveClass('pointer-events-auto')
+ })
+
+ it.each([
+ ['succeeded', NodeRunningStatus.Succeeded, 'after:bg-workflow-link-line-success-handle'],
+ ['failed', NodeRunningStatus.Failed, 'after:bg-workflow-link-line-error-handle'],
+ ['exception', NodeRunningStatus.Exception, 'after:bg-workflow-link-line-failure-handle'],
+ ])('should render the target %s status class', (_label, runningStatus, expectedClass) => {
+ renderTargetHandle({
+ _runningStatus: runningStatus,
+ })
+
+ expect(screen.getByTestId('handle-target-handle')).toHaveClass(expectedClass)
+ expect(screen.getByTestId('handle-target-handle')).toHaveClass('custom-target-handle')
+ })
+ })
+
+ // Source-side tests cover selector opening paths, previous-node selection, and status styling.
+ describe('NodeSourceHandle', () => {
+ it('should toggle the source add trigger and select the previous node', () => {
+ renderSourceHandle()
+
+ const handle = screen.getByTestId('handle-source-handle')
+ const addNodeButton = getAddNodeButton()
+
+ expect(addNodeButton).toHaveClass('opacity-0')
+
+ fireEvent.click(addNodeButton)
+
+ expect(addNodeButton).toHaveClass('opacity-100')
+ expect(addNodeButton).toHaveClass('pointer-events-auto')
+
+ fireEvent.click(getSelectNodeButton())
+
+ expect(mockHandleNodeAdd).toHaveBeenCalledWith(
+ {
+ nodeType: BlockEnum.Answer,
+ pluginDefaultValue: { pluginId: 'plugin-1' },
+ },
+ {
+ prevNodeId: 'source-node',
+ prevNodeSourceHandle: 'source-handle',
+ },
+ )
+
+ fireEvent.click(handle)
+
+ expect(addNodeButton).toHaveClass('opacity-0')
+ })
+
+ it('should keep the source add trigger visible when the node is selected', () => {
+ renderSourceHandle({
+ selected: true,
+ })
+
+ const addNodeButton = getAddNodeButton()
+
+ expect(addNodeButton).toHaveClass('custom-selector')
+ expect(addNodeButton).toHaveClass('opacity-100')
+ expect(addNodeButton).toHaveClass('pointer-events-auto')
+ })
+
+ it.each([
+ ['succeeded', NodeRunningStatus.Succeeded, undefined, 'after:bg-workflow-link-line-success-handle'],
+ ['failed', NodeRunningStatus.Failed, undefined, 'after:bg-workflow-link-line-error-handle'],
+ ['exception', NodeRunningStatus.Exception, true, 'after:bg-workflow-link-line-failure-handle'],
+ ])('should render the source %s status class', (_label, runningStatus, showExceptionStatus, expectedClass) => {
+ renderSourceHandle(
+ {
+ _runningStatus: runningStatus,
+ },
+ {
+ showExceptionStatus,
+ },
+ )
+
+ expect(screen.getByTestId('handle-source-handle')).toHaveClass(expectedClass)
+ expect(screen.getByTestId('handle-source-handle')).toHaveClass('custom-source-handle')
+ })
+ })
+
+ // Auto-open tests cover workflow start-trigger variants, chat-mode bypass, and store fallback paths.
+ describe('NodeSourceHandle auto-open', () => {
+ it.each([
+ BlockEnum.Start,
+ BlockEnum.TriggerSchedule,
+ BlockEnum.TriggerWebhook,
+ BlockEnum.TriggerPlugin,
+ ])('should auto-open immediately for %s nodes', (type) => {
+ mockStoreState.shouldAutoOpenStartNodeSelector = true
+
+ renderSourceHandle({ type })
+
+ const addNodeButton = getAddNodeButton()
+
+ expect(addNodeButton).toHaveClass('opacity-100')
+ expect(addNodeButton).toHaveClass('pointer-events-auto')
+ expect(mockSetShouldAutoOpenStartNodeSelector).toHaveBeenCalledWith(false)
+ expect(mockSetHasSelectedStartNode).toHaveBeenCalledWith(false)
+ })
+
+ it('should skip source auto-open in chat mode and only reset the start selector flag', () => {
+ mockHooksState.isChatMode = true
+ mockStoreState.shouldAutoOpenStartNodeSelector = true
+
+ renderSourceHandle({ type: BlockEnum.Start })
+
+ expect(getAddNodeButton()).toHaveClass('opacity-0')
+ expect(mockSetShouldAutoOpenStartNodeSelector).toHaveBeenCalledWith(false)
+ expect(mockSetHasSelectedStartNode).not.toHaveBeenCalled()
+ })
+
+ it('should use the workflow store fallback when the selector setters are unavailable', () => {
+ mockStoreState.shouldAutoOpenStartNodeSelector = true
+ mockStoreState.setShouldAutoOpenStartNodeSelector = undefined
+ mockStoreState.setHasSelectedStartNode = undefined
+
+ renderSourceHandle({ type: BlockEnum.Start })
+
+ expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ shouldAutoOpenStartNodeSelector: false })
+ expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ hasSelectedStartNode: false })
+ })
+
+ it('should not auto-open when the node type is not a workflow entry node', () => {
+ mockStoreState.shouldAutoOpenStartNodeSelector = true
+
+ renderSourceHandle({ type: BlockEnum.Code })
+
+ expect(getAddNodeButton()).toHaveClass('opacity-0')
+ expect(mockSetShouldAutoOpenStartNodeSelector).not.toHaveBeenCalled()
+ expect(mockSetHasSelectedStartNode).not.toHaveBeenCalled()
+ expect(mockWorkflowStoreSetState).not.toHaveBeenCalled()
+ })
+ })
+})
diff --git a/web/app/components/workflow/nodes/_base/components/node-handle.tsx b/web/app/components/workflow/nodes/_base/components/node-handle.tsx
index 0bcdfc0f46..e84b09ac95 100644
--- a/web/app/components/workflow/nodes/_base/components/node-handle.tsx
+++ b/web/app/components/workflow/nodes/_base/components/node-handle.tsx
@@ -36,6 +36,16 @@ type NodeHandleProps = {
showExceptionStatus?: boolean
} & Pick
+const canAutoOpenStartNodeSelector = (nodeType: BlockEnum, isChatMode: boolean) => {
+ if (isChatMode)
+ return false
+
+ return nodeType === BlockEnum.Start
+ || nodeType === BlockEnum.TriggerSchedule
+ || nodeType === BlockEnum.TriggerWebhook
+ || nodeType === BlockEnum.TriggerPlugin
+}
+
export const NodeTargetHandle = memo(({
id,
data,
@@ -103,11 +113,11 @@ export const NodeTargetHandle = memo(({
asChild
placement="left"
triggerClassName={open => `
- hidden absolute left-0 top-0 pointer-events-none
+ absolute left-0 top-0 opacity-0 pointer-events-none transition-opacity duration-150
${nodeSelectorClassName}
- group-hover:flex!
- ${data.selected && 'flex!'}
- ${open && 'flex!'}
+ group-hover:opacity-100 group-hover:pointer-events-auto
+ ${data.selected && 'opacity-100 pointer-events-auto'}
+ ${open && 'opacity-100 pointer-events-auto'}
`}
availableBlocksTypes={availablePrevBlocks}
/>
@@ -132,12 +142,13 @@ export const NodeSourceHandle = memo(({
const setShouldAutoOpenStartNodeSelector = useStore(s => s.setShouldAutoOpenStartNodeSelector)
const setHasSelectedStartNode = useStore(s => s.setHasSelectedStartNode)
const workflowStoreApi = useWorkflowStore()
- const [open, setOpen] = useState(false)
const { handleNodeAdd } = useNodesInteractions()
const { getNodesReadOnly } = useNodesReadOnly()
const { availableNextBlocks } = useAvailableBlocks(data.type, data.isInIteration || data.isInLoop)
const isConnectable = !!availableNextBlocks.length
const isChatMode = useIsChatMode()
+ const shouldAutoOpen = shouldAutoOpenStartNodeSelector && canAutoOpenStartNodeSelector(data.type, isChatMode)
+ const [open, setOpen] = useState(() => shouldAutoOpen)
const connected = data._connectedSourceHandleIds?.includes(handleId)
const handleOpenChange = useCallback((v: boolean) => {
@@ -169,8 +180,7 @@ export const NodeSourceHandle = memo(({
return
}
- if (data.type === BlockEnum.Start || data.type === BlockEnum.TriggerSchedule || data.type === BlockEnum.TriggerWebhook || data.type === BlockEnum.TriggerPlugin) {
- setOpen(true)
+ if (canAutoOpenStartNodeSelector(data.type, false)) {
if (setShouldAutoOpenStartNodeSelector)
setShouldAutoOpenStartNodeSelector(false)
else
@@ -221,11 +231,11 @@ export const NodeSourceHandle = memo(({
onSelect={handleSelect}
asChild
triggerClassName={open => `
- hidden absolute top-0 left-0 pointer-events-none
+ absolute top-0 left-0 opacity-0 pointer-events-none transition-opacity duration-150
${nodeSelectorClassName}
- group-hover:flex!
- ${data.selected && 'flex!'}
- ${open && 'flex!'}
+ group-hover:opacity-100 group-hover:pointer-events-auto
+ ${data.selected && 'opacity-100 pointer-events-auto'}
+ ${open && 'opacity-100 pointer-events-auto'}
`}
availableBlocksTypes={availableNextBlocks}
/>