-
-
{file.name}
-
{formatFileSize(file.size)}
+
+ {file.name}
+ ({formatFileSize(file.size)})
@@ -588,11 +452,43 @@ export function FileUpload({
)
}
- // Get files array regardless of multiple setting
const filesArray = Array.isArray(value) ? value : value ? [value] : []
const hasFiles = filesArray.length > 0
const isUploading = uploadingFiles.length > 0
+ const comboboxOptions = useMemo(
+ () => [
+ { label: 'Upload New File', value: '__upload_new__' },
+ ...availableWorkspaceFiles.map((file) => ({
+ label: file.name,
+ value: file.id,
+ })),
+ ],
+ [availableWorkspaceFiles]
+ )
+
+ const handleComboboxChange = (value: string) => {
+ setInputValue(value)
+
+ const isValidOption =
+ value === '__upload_new__' || availableWorkspaceFiles.some((file) => file.id === value)
+
+ if (!isValidOption) {
+ return
+ }
+
+ setInputValue('')
+
+ if (value === '__upload_new__') {
+ handleOpenFileDialog({
+ preventDefault: () => {},
+ stopPropagation: () => {},
+ } as React.MouseEvent)
+ } else {
+ handleSelectWorkspaceFile(value)
+ }
+ }
+
return (
e.stopPropagation()}>
{/* Only show files that aren't currently uploading */}
{filesArray.map((file) => {
- // Don't show files that have duplicates in the uploading list
const isCurrentlyUploading = uploadingFiles.some(
(uploadingFile) => uploadingFile.name === file.name
)
@@ -641,73 +536,19 @@ export function FileUpload({
{/* Add More dropdown for multiple files */}
{hasFiles && multiple && !isUploading && (
-
{
- setAddMoreOpen(open)
if (open) void loadWorkspaceFiles()
}}
- >
-
-
-
-
-
-
- e.stopPropagation()}>
-
- {
- setAddMoreOpen(false)
- handleOpenFileDialog({
- preventDefault: () => {},
- stopPropagation: () => {},
- } as React.MouseEvent)
- }}
- >
- Upload New File
-
-
-
- {availableWorkspaceFiles.length === 0
- ? 'No files available.'
- : 'No files found.'}
-
- {availableWorkspaceFiles.length > 0 && (
-
- {availableWorkspaceFiles.map((file) => (
- {
- handleSelectWorkspaceFile(file.id)
- setAddMoreOpen(false)
- }}
- >
-
- {truncateMiddle(file.name)}
-
-
- ))}
-
- )}
-
-
-
-
+ placeholder={loadingWorkspaceFiles ? 'Loading files...' : '+ Add More'}
+ disabled={disabled || loadingWorkspaceFiles}
+ editable={true}
+ filterOptions={true}
+ isLoading={loadingWorkspaceFiles}
+ />
)}
@@ -715,75 +556,19 @@ export function FileUpload({
{/* Show dropdown selector if no files and not uploading */}
{!hasFiles && !isUploading && (
-
{
- setPickerOpen(open)
if (open) void loadWorkspaceFiles()
}}
- >
-
-
-
-
-
-
- e.stopPropagation()}>
-
- {
- setPickerOpen(false)
- handleOpenFileDialog({
- preventDefault: () => {},
- stopPropagation: () => {},
- } as React.MouseEvent)
- }}
- >
- Upload New File
-
-
-
- {availableWorkspaceFiles.length === 0
- ? 'No files available.'
- : 'No files found.'}
-
- {availableWorkspaceFiles.length > 0 && (
-
- {availableWorkspaceFiles.map((file) => (
- {
- handleSelectWorkspaceFile(file.id)
- setPickerOpen(false)
- }}
- >
-
- {truncateMiddle(file.name)}
-
-
- ))}
-
- )}
-
-
-
-
+ placeholder={loadingWorkspaceFiles ? 'Loading files...' : 'Select or upload file'}
+ disabled={disabled || loadingWorkspaceFiles}
+ editable={true}
+ filterOptions={true}
+ isLoading={loadingWorkspaceFiles}
+ />
)}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/folder-selector/components/folder-selector-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/folder-selector/components/folder-selector-input.tsx
index 26a203e54d8..8f53e07a4e1 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/folder-selector/components/folder-selector-input.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/folder-selector/components/folder-selector-input.tsx
@@ -1,14 +1,12 @@
'use client'
-import { useCallback, useEffect, useState } from 'react'
-import {
- type FolderInfo,
- FolderSelector,
-} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/folder-selector/folder-selector'
+import { useCallback, useEffect, useMemo, useState } from 'react'
+import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-foreign-credential'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-value'
import type { SubBlockConfig } from '@/blocks/types'
+import { resolveSelectorForSubBlock } from '@/hooks/selectors/resolution'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -27,19 +25,19 @@ export function FolderSelectorInput({
isPreview = false,
previewValue,
}: FolderSelectorInputProps) {
- const [storeValue, _setStoreValue] = useSubBlockValue(blockId, subBlock.id)
+ const [storeValue] = useSubBlockValue(blockId, subBlock.id)
const [connectedCredential] = useSubBlockValue(blockId, 'credential')
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
const { activeWorkflowId } = useWorkflowRegistry()
const [selectedFolderId, setSelectedFolderId] = useState
('')
- const [_folderInfo, setFolderInfo] = useState(null)
- const provider = (subBlock.provider || subBlock.serviceId || 'google-email').toLowerCase()
+ const providerKey = (subBlock.provider ?? subBlock.serviceId ?? '').toLowerCase()
+ const credentialProvider = subBlock.serviceId ?? subBlock.provider
const isCopyDestinationSelector =
subBlock.canonicalParamId === 'copyDestinationId' ||
subBlock.id === 'copyDestinationFolder' ||
subBlock.id === 'manualCopyDestinationFolder'
const { isForeignCredential } = useForeignCredential(
- subBlock.provider || subBlock.serviceId || 'outlook',
+ credentialProvider,
(connectedCredential as string) || ''
)
@@ -48,26 +46,22 @@ export function FolderSelectorInput({
// Get the current value from the store or prop value if in preview mode
useEffect(() => {
- // When gated/disabled, do not set defaults or write to store
if (finalDisabled) return
if (isPreview && previewValue !== undefined) {
setSelectedFolderId(previewValue)
return
}
const current = storeValue as string | undefined
- if (current && typeof current === 'string') {
+ if (current) {
setSelectedFolderId(current)
return
}
- const shouldDefaultInbox = provider !== 'outlook' && !isCopyDestinationSelector
+ const shouldDefaultInbox = providerKey === 'gmail' && !isCopyDestinationSelector
if (shouldDefaultInbox) {
- const defaultValue = 'INBOX'
- setSelectedFolderId(defaultValue)
+ setSelectedFolderId('INBOX')
if (!isPreview) {
- collaborativeSetSubblockValue(blockId, subBlock.id, defaultValue)
+ collaborativeSetSubblockValue(blockId, subBlock.id, 'INBOX')
}
- } else {
- setSelectedFolderId('')
}
}, [
blockId,
@@ -77,33 +71,46 @@ export function FolderSelectorInput({
isPreview,
previewValue,
finalDisabled,
+ providerKey,
+ isCopyDestinationSelector,
])
- // Handle folder selection
- const handleFolderChange = useCallback(
- (folderId: string, info?: FolderInfo) => {
- setSelectedFolderId(folderId)
- setFolderInfo(info || null)
+ const credentialId = (connectedCredential as string) || ''
+ const missingCredential = credentialId.length === 0
+ const selectorResolution = useMemo(
+ () =>
+ resolveSelectorForSubBlock(subBlock, {
+ credentialId: credentialId || undefined,
+ workflowId: activeWorkflowId || undefined,
+ }),
+ [subBlock, credentialId, activeWorkflowId]
+ )
+
+ const handleChange = useCallback(
+ (value: string) => {
+ setSelectedFolderId(value)
if (!isPreview) {
- collaborativeSetSubblockValue(blockId, subBlock.id, folderId)
+ collaborativeSetSubblockValue(blockId, subBlock.id, value)
}
},
[blockId, subBlock.id, collaborativeSetSubblockValue, isPreview]
)
return (
-
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/folder-selector/folder-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/folder-selector/folder-selector.tsx
deleted file mode 100644
index 925a40d3ee8..00000000000
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/folder-selector/folder-selector.tsx
+++ /dev/null
@@ -1,533 +0,0 @@
-'use client'
-
-import { useCallback, useEffect, useRef, useState } from 'react'
-import { Check, ChevronDown, RefreshCw } from 'lucide-react'
-import { GmailIcon, OutlookIcon } from '@/components/icons'
-import { Button } from '@/components/ui/button'
-import {
- Command,
- CommandEmpty,
- CommandGroup,
- CommandInput,
- CommandItem,
- CommandList,
-} from '@/components/ui/command'
-import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
-import { createLogger } from '@/lib/logs/console/logger'
-import { type Credential, getProviderIdFromServiceId, getServiceIdFromScopes } from '@/lib/oauth'
-import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
-import { useDisplayNamesStore } from '@/stores/display-names/store'
-
-const logger = createLogger('FolderSelector')
-
-export interface FolderInfo {
- id: string
- name: string
- type: string
- messagesTotal?: number
- messagesUnread?: number
-}
-
-interface FolderSelectorProps {
- value: string
- onChange: (value: string, folderInfo?: FolderInfo) => void
- provider: string
- requiredScopes?: string[]
- label?: string
- disabled?: boolean
- serviceId?: string
- onFolderInfoChange?: (folderInfo: FolderInfo | null) => void
- isPreview?: boolean
- previewValue?: any | null
- credentialId?: string
- workflowId?: string
- isForeignCredential?: boolean
-}
-
-export function FolderSelector({
- value,
- onChange,
- provider,
- requiredScopes = [],
- label = 'Select folder',
- disabled = false,
- serviceId,
- onFolderInfoChange,
- isPreview = false,
- previewValue,
- credentialId,
- workflowId,
- isForeignCredential = false,
-}: FolderSelectorProps) {
- const [open, setOpen] = useState(false)
- const [credentials, setCredentials] = useState([])
- const [folders, setFolders] = useState([])
- const [selectedCredentialId, setSelectedCredentialId] = useState(
- credentialId || ''
- )
- const [selectedFolderId, setSelectedFolderId] = useState('')
- const [isLoading, setIsLoading] = useState(false)
- const [isLoadingSelectedFolder, setIsLoadingSelectedFolder] = useState(false)
- const [showOAuthModal, setShowOAuthModal] = useState(false)
- const initialFetchRef = useRef(false)
-
- // Get cached display name
- const cachedFolderName = useDisplayNamesStore(
- useCallback(
- (state) => {
- const effectiveCredentialId = credentialId || selectedCredentialId
- const effectiveValue = isPreview && previewValue !== undefined ? previewValue : value
- if (!effectiveCredentialId || !effectiveValue) return null
- return state.cache.folders[effectiveCredentialId]?.[effectiveValue] || null
- },
- [credentialId, selectedCredentialId, value, isPreview, previewValue]
- )
- )
-
- // Initialize selectedFolderId with the effective value
- useEffect(() => {
- if (isPreview && previewValue !== undefined) {
- setSelectedFolderId(previewValue || '')
- } else {
- setSelectedFolderId(value)
- }
- }, [value, isPreview, previewValue])
-
- // Keep internal credential in sync with prop
- useEffect(() => {
- if (credentialId && credentialId !== selectedCredentialId) {
- setSelectedCredentialId(credentialId)
- }
- }, [credentialId, selectedCredentialId])
-
- // Determine the appropriate service ID based on provider and scopes
- const getServiceId = (): string => {
- if (serviceId) return serviceId
- return getServiceIdFromScopes(provider, requiredScopes)
- }
-
- // Determine the appropriate provider ID based on service and scopes
- const getProviderId = (): string => {
- const effectiveServiceId = getServiceId()
- return getProviderIdFromServiceId(effectiveServiceId)
- }
-
- // Fetch available credentials for this provider
- const fetchCredentials = useCallback(async () => {
- setIsLoading(true)
- try {
- const providerId = getProviderId()
- const response = await fetch(`/api/auth/oauth/credentials?provider=${providerId}`)
-
- if (response.ok) {
- const data = await response.json()
- setCredentials(data.credentials)
-
- // Auto-select logic for credentials
- if (data.credentials.length > 0) {
- // If we already have a selected credential ID, check if it's valid
- if (
- selectedCredentialId &&
- data.credentials.some((cred: Credential) => cred.id === selectedCredentialId)
- ) {
- // Keep the current selection
- } else {
- // Otherwise, select the default or first credential
- const defaultCred = data.credentials.find((cred: Credential) => cred.isDefault)
- if (defaultCred) {
- setSelectedCredentialId(defaultCred.id)
- } else if (data.credentials.length === 1) {
- setSelectedCredentialId(data.credentials[0].id)
- }
- }
- }
- }
- } catch (error) {
- logger.error('Error fetching credentials:', { error })
- } finally {
- setIsLoading(false)
- }
- }, [provider, getProviderId, selectedCredentialId])
-
- // Fetch a single folder by ID when we have a selectedFolderId but no metadata
- const fetchFolderById = useCallback(
- async (folderId: string) => {
- if (!selectedCredentialId || !folderId) return null
-
- setIsLoadingSelectedFolder(true)
- try {
- if (provider === 'outlook') {
- // Resolve Outlook folder name with owner-scoped token
- const tokenRes = await fetch('/api/auth/oauth/token', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ credentialId: selectedCredentialId, workflowId }),
- })
- if (!tokenRes.ok) return null
- const { accessToken } = await tokenRes.json()
- if (!accessToken) return null
- const resp = await fetch(
- `https://graph.microsoft.com/v1.0/me/mailFolders/${encodeURIComponent(folderId)}`,
- {
- headers: { Authorization: `Bearer ${accessToken}` },
- }
- )
- if (!resp.ok) return null
- const folder = await resp.json()
- const folderInfo: FolderInfo = {
- id: folder.id,
- name: folder.displayName,
- type: 'folder',
- messagesTotal: folder.totalItemCount,
- messagesUnread: folder.unreadItemCount,
- }
- onFolderInfoChange?.(folderInfo)
- return folderInfo
- }
- // Gmail label resolution
- const queryParams = new URLSearchParams({
- credentialId: selectedCredentialId,
- labelId: folderId,
- })
- const response = await fetch(`/api/tools/gmail/label?${queryParams.toString()}`)
- if (response.ok) {
- const data = await response.json()
- if (data.label) {
- onFolderInfoChange?.(data.label)
- return data.label
- }
- } else {
- logger.error('Error fetching folder by ID:', {
- error: await response.text(),
- })
- }
- return null
- } catch (error) {
- logger.error('Error fetching folder by ID:', { error })
- return null
- } finally {
- setIsLoadingSelectedFolder(false)
- }
- },
- [selectedCredentialId, onFolderInfoChange, provider, workflowId]
- )
-
- // Fetch folders from Gmail or Outlook
- const fetchFolders = useCallback(
- async (searchQuery?: string) => {
- if (!selectedCredentialId) return
-
- setIsLoading(true)
- try {
- // Construct query parameters
- const queryParams = new URLSearchParams({
- credentialId: selectedCredentialId,
- })
-
- if (searchQuery) {
- queryParams.append('query', searchQuery)
- }
-
- // Determine the API endpoint based on provider
- let apiEndpoint: string
- if (provider === 'outlook') {
- // Skip list fetch for collaborators; only show selected
- if (isForeignCredential) {
- setFolders([])
- setIsLoading(false)
- return
- }
- apiEndpoint = `/api/tools/outlook/folders?${queryParams.toString()}`
- } else {
- // Default to Gmail
- apiEndpoint = `/api/tools/gmail/labels?${queryParams.toString()}`
- }
-
- const response = await fetch(apiEndpoint)
-
- if (response.ok) {
- const data = await response.json()
- const folderList = provider === 'outlook' ? data.folders : data.labels
- setFolders(folderList || [])
-
- // Cache folder names in display names store
- if (selectedCredentialId && folderList) {
- const folderMap = folderList.reduce(
- (acc: Record, folder: FolderInfo) => {
- acc[folder.id] = folder.name
- return acc
- },
- {}
- )
- useDisplayNamesStore
- .getState()
- .setDisplayNames('folders', selectedCredentialId, folderMap)
- }
-
- // Only notify parent if callback exists
- if (selectedFolderId && onFolderInfoChange) {
- const folderInfo = folderList.find(
- (folder: FolderInfo) => folder.id === selectedFolderId
- )
- if (folderInfo) {
- onFolderInfoChange(folderInfo)
- } else if (!searchQuery && provider !== 'outlook') {
- // Only try to fetch by ID for Gmail if this is not a search query
- // and we couldn't find the folder in the list
- fetchFolderById(selectedFolderId)
- }
- }
- } else {
- const text = await response.text()
- if (response.status === 401 || response.status === 403) {
- logger.info('Folder list fetch unauthorized (expected for collaborator)')
- } else {
- logger.warn('Error fetching folders', { status: response.status, text })
- }
- setFolders([])
- }
- } catch (error) {
- logger.error('Error fetching folders:', { error })
- setFolders([])
- } finally {
- setIsLoading(false)
- }
- },
- [
- selectedCredentialId,
- selectedFolderId,
- onFolderInfoChange,
- fetchFolderById,
- provider,
- isForeignCredential,
- ]
- )
-
- // Fetch credentials on initial mount
- useEffect(() => {
- if (disabled) return
- if (!initialFetchRef.current) {
- fetchCredentials()
- initialFetchRef.current = true
- }
- }, [fetchCredentials, disabled])
-
- // Fetch folders when credential is selected
- useEffect(() => {
- if (disabled) return
- if (selectedCredentialId) {
- fetchFolders()
- }
- }, [selectedCredentialId, fetchFolders, disabled])
-
- // Keep internal selectedFolderId in sync with the value prop
- useEffect(() => {
- if (disabled) return
- const currentValue = isPreview ? previewValue : value
- if (currentValue !== selectedFolderId) {
- setSelectedFolderId(currentValue || '')
- }
- }, [value, isPreview, previewValue, disabled, selectedFolderId])
-
- // Handle folder selection
- const handleSelectFolder = (folder: FolderInfo) => {
- setSelectedFolderId(folder.id)
- if (!isPreview) {
- onChange(folder.id, folder)
- }
- onFolderInfoChange?.(folder)
- setOpen(false)
- }
-
- // Handle adding a new credential
- const handleAddCredential = () => {
- // Show the OAuth modal
- setShowOAuthModal(true)
- setOpen(false)
- }
-
- const handleSearch = (value: string) => {
- if (value.length > 2) {
- fetchFolders(value)
- } else if (value.length === 0) {
- fetchFolders()
- }
- }
-
- const getFolderIcon = (size: 'sm' | 'md' = 'sm') => {
- const iconSize = size === 'sm' ? 'h-4 w-4' : 'h-5 w-5'
- if (provider === 'gmail') {
- return
- }
- if (provider === 'outlook') {
- return
- }
- return null
- }
-
- const getProviderName = () => {
- if (provider === 'outlook') return 'Outlook'
- return 'Gmail'
- }
-
- const getFolderLabel = () => {
- if (provider === 'outlook') return 'folders'
- return 'labels'
- }
-
- return (
- <>
-
-
-
-
-
- {!isForeignCredential && (
-
- {/* Current account indicator */}
- {selectedCredentialId && credentials.length > 0 && (
-
-
-
- {credentials.find((cred) => cred.id === selectedCredentialId)?.name ||
- 'Unknown'}
-
-
- {credentials.length > 1 && (
-
- )}
-
- )}
-
-
-
-
-
- {isLoading ? (
-
-
- Loading {getFolderLabel()}...
-
- ) : credentials.length === 0 ? (
-
-
No accounts connected.
-
- Connect a {getProviderName()} account to continue.
-
-
- ) : (
-
-
No {getFolderLabel()} found.
-
- Try a different search or account.
-
-
- )}
-
-
- {/* Account selection - only show if we have multiple accounts */}
- {credentials.length > 1 && (
-
-
- Switch Account
-
- {credentials.map((cred) => (
- setSelectedCredentialId(cred.id)}
- >
-
- {cred.name}
-
- {cred.id === selectedCredentialId && (
-
- )}
-
- ))}
-
- )}
-
- {/* Folders list */}
- {folders.length > 0 && (
-
-
- {getFolderLabel().charAt(0).toUpperCase() + getFolderLabel().slice(1)}
-
- {folders.map((folder) => (
- handleSelectFolder(folder)}
- >
-
- {getFolderIcon('sm')}
- {folder.name}
- {folder.id === selectedFolderId && (
-
- )}
-
-
- ))}
-
- )}
-
- {/* Connect account option - only show if no credentials */}
- {credentials.length === 0 && (
-
-
-
- Connect {getProviderName()} account
-
-
-
- )}
-
-
-
- )}
-
-
-
- {showOAuthModal && (
- setShowOAuthModal(false)}
- provider={provider}
- toolName={getProviderName()}
- requiredScopes={requiredScopes}
- serviceId={getServiceId()}
- />
- )}
- >
- )
-}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/mcp-dynamic-args/mcp-dynamic-args.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/mcp-dynamic-args/mcp-dynamic-args.tsx
index 0d6e0f5b22c..1ac63dc2af5 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/mcp-dynamic-args/mcp-dynamic-args.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/mcp-dynamic-args/mcp-dynamic-args.tsx
@@ -1,17 +1,8 @@
-import { useCallback, useRef, useState } from 'react'
+import { useCallback, useMemo, useRef, useState } from 'react'
import { useParams } from 'next/navigation'
-import { Input } from '@/components/ui/input'
-import { Label } from '@/components/ui/label'
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from '@/components/ui/select'
+import { Combobox, Input, Label, Textarea } from '@/components/emcn/components'
import { Slider } from '@/components/ui/slider'
import { Switch } from '@/components/ui/switch'
-import { Textarea } from '@/components/ui/textarea'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/formatted-text'
@@ -174,7 +165,6 @@ function McpTextareaWithTags({
onChange(newValue)
setCursorPosition(newCursorPosition)
- // Check for tag trigger
const tagTrigger = checkTagTrigger(newValue, newCursorPosition)
setShowTags(tagTrigger.show)
}
@@ -308,7 +298,6 @@ export function McpDynamicArgs({
if (disabled) return
const current = currentArgs()
- // Store the value as-is, preserving types (number, boolean, etc.)
const updated = { ...current, [paramName]: value }
setToolArgs(updated)
},
@@ -357,29 +346,38 @@ export function McpDynamicArgs({
)
- case 'dropdown':
+ case 'dropdown': {
+ const dropdownOptions = useMemo(
+ () =>
+ (paramSchema.enum || []).map((option: any) => ({
+ label: String(option),
+ value: String(option),
+ })),
+ [paramSchema.enum]
+ )
+
return (
-
+ editable={false}
+ filterOptions={true}
+ />
)
+ }
case 'slider': {
const minValue = paramSchema.minimum ?? 0
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/mcp-server-modal/mcp-server-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/mcp-server-modal/mcp-server-selector.tsx
index 67d955c149b..c3ea65cc6a5 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/mcp-server-modal/mcp-server-selector.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/mcp-server-modal/mcp-server-selector.tsx
@@ -1,18 +1,8 @@
'use client'
-import { useState } from 'react'
-import { Check, ChevronDown, RefreshCw } from 'lucide-react'
+import { useEffect, useMemo, useState } from 'react'
import { useParams } from 'next/navigation'
-import { Button } from '@/components/ui/button'
-import {
- Command,
- CommandEmpty,
- CommandGroup,
- CommandInput,
- CommandItem,
- CommandList,
-} from '@/components/ui/command'
-import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
+import { Combobox } from '@/components/emcn/components'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-value'
import type { SubBlockConfig } from '@/blocks/types'
import { useMcpServers } from '@/hooks/queries/mcp'
@@ -34,7 +24,7 @@ export function McpServerSelector({
}: McpServerSelectorProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
- const [open, setOpen] = useState(false)
+ const [inputValue, setInputValue] = useState('')
const { data: servers = [], isLoading, error } = useMcpServers(workspaceId)
const enabledServers = servers.filter((s) => s.enabled && !s.deletedAt)
@@ -48,87 +38,47 @@ export function McpServerSelector({
const selectedServer = enabledServers.find((server) => server.id === selectedServerId)
- const handleOpenChange = (isOpen: boolean) => {
- setOpen(isOpen)
- // React Query automatically keeps server list fresh
- }
+ const comboboxOptions = useMemo(
+ () =>
+ enabledServers.map((server) => ({
+ label: server.name,
+ value: server.id,
+ })),
+ [enabledServers]
+ )
- const handleSelect = (serverId: string) => {
- if (!isPreview) {
- setStoreValue(serverId)
+ const handleComboboxChange = (value: string) => {
+ const matchedServer = enabledServers.find((s) => s.id === value)
+ if (matchedServer) {
+ setInputValue(matchedServer.name)
+ if (!isPreview) {
+ setStoreValue(value)
+ }
+ } else {
+ setInputValue(value)
}
- setOpen(false)
}
- const getDisplayText = () => {
+ useEffect(() => {
if (selectedServer) {
- return
{selectedServer.name}
+ setInputValue(selectedServer.name)
+ } else {
+ setInputValue('')
}
- return
{label}
- }
+ }, [selectedServer])
return (
-
-
-
-
-
-
-
-
-
- {isLoading ? (
-
-
- Loading servers...
-
- ) : error ? (
-
-
Error loading servers
-
- {error instanceof Error ? error.message : 'Unknown error'}
-
-
- ) : (
-
-
No MCP servers found
-
- Configure MCP servers in workspace settings
-
-
- )}
-
- {enabledServers.length > 0 && (
-
- {enabledServers.map((server) => (
- handleSelect(server.id)}
- className='cursor-pointer'
- >
-
- {server.name}
-
- {server.id === selectedServerId && }
-
- ))}
-
- )}
-
-
-
-
+
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/mcp-server-modal/mcp-tool-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/mcp-server-modal/mcp-tool-selector.tsx
index 49e2567a4ba..1da2c589e19 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/mcp-server-modal/mcp-tool-selector.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/mcp-server-modal/mcp-tool-selector.tsx
@@ -1,18 +1,8 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
-import { Check, ChevronDown, RefreshCw } from 'lucide-react'
import { useParams } from 'next/navigation'
-import { Button } from '@/components/ui/button'
-import {
- Command,
- CommandEmpty,
- CommandGroup,
- CommandInput,
- CommandItem,
- CommandList,
-} from '@/components/ui/command'
-import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
+import { Combobox } from '@/components/emcn/components'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-value'
import type { SubBlockConfig } from '@/blocks/types'
import { useMcpTools } from '@/hooks/use-mcp-tools'
@@ -34,7 +24,7 @@ export function McpToolSelector({
}: McpToolSelectorProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
- const [open, setOpen] = useState(false)
+ const [inputValue, setInputValue] = useState('')
const { mcpTools, isLoading, error, refreshTools, getToolsByServer } = useMcpTools(workspaceId)
@@ -73,105 +63,59 @@ export function McpToolSelector({
}
}, [serverValue, availableTools, storeValue, setStoreValue, isPreview, disabled])
- const handleOpenChange = (isOpen: boolean) => {
- setOpen(isOpen)
- if (isOpen && serverValue) {
- refreshTools()
+ const comboboxOptions = useMemo(
+ () =>
+ availableTools.map((tool) => ({
+ label: tool.name,
+ value: tool.id,
+ })),
+ [availableTools]
+ )
+
+ const handleComboboxChange = (value: string) => {
+ const matchedTool = availableTools.find((t) => t.id === value)
+ if (matchedTool) {
+ setInputValue(matchedTool.name)
+ if (!isPreview) {
+ setStoreValue(value)
+ if (matchedTool.inputSchema) {
+ setSchemaCache(matchedTool.inputSchema)
+ }
+ }
+ } else {
+ setInputValue(value)
}
}
- const handleSelect = (toolId: string) => {
- if (!isPreview) {
- setStoreValue(toolId)
-
- const tool = availableTools.find((t) => t.id === toolId)
- if (tool?.inputSchema) {
- setSchemaCache(tool.inputSchema)
- }
+ const handleOpenChange = (isOpen: boolean) => {
+ if (isOpen && serverValue) {
+ refreshTools()
}
- setOpen(false)
}
- const getDisplayText = () => {
+ useEffect(() => {
if (selectedTool) {
- return
{selectedTool.name}
+ setInputValue(selectedTool.name)
+ } else {
+ setInputValue('')
}
- return (
-
- {serverValue ? label : 'Select server first'}
-
- )
- }
+ }, [selectedTool])
const isDisabled = disabled || !serverValue
return (
-
-
-
-
-
-
-
-
-
- {isLoading ? (
-
-
- Loading tools...
-
- ) : error ? (
-
-
Error loading tools
-
{error}
-
- ) : !serverValue ? (
-
-
No server selected
-
- Select an MCP server first to see available tools
-
-
- ) : (
-
-
No tools found
-
- The selected server has no available tools
-
-
- )}
-
- {availableTools.length > 0 && (
-
- {availableTools.map((tool) => (
- handleSelect(tool.id)}
- className='cursor-pointer'
- >
-
- {tool.name}
-
- {tool.id === selectedToolId && }
-
- ))}
-
- )}
-
-
-
-
+
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/project-selector/components/jira-project-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/project-selector/components/jira-project-selector.tsx
deleted file mode 100644
index 1f795eee500..00000000000
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/project-selector/components/jira-project-selector.tsx
+++ /dev/null
@@ -1,638 +0,0 @@
-'use client'
-
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
-import { Check, ChevronDown, ExternalLink, RefreshCw, X } from 'lucide-react'
-import { JiraIcon } from '@/components/icons'
-import { Button } from '@/components/ui/button'
-import {
- Command,
- CommandEmpty,
- CommandGroup,
- CommandInput,
- CommandItem,
- CommandList,
-} from '@/components/ui/command'
-import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
-import { createLogger } from '@/lib/logs/console/logger'
-import {
- type Credential,
- getProviderIdFromServiceId,
- getServiceIdFromScopes,
- type OAuthProvider,
-} from '@/lib/oauth'
-import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
-import { useDisplayNamesStore } from '@/stores/display-names/store'
-
-const logger = createLogger('JiraProjectSelector')
-
-export interface JiraProjectInfo {
- id: string
- key: string
- name: string
- url?: string
- avatarUrl?: string
- description?: string
- projectTypeKey?: string
- simplified?: boolean
- style?: string
- isPrivate?: boolean
-}
-
-interface JiraProjectSelectorProps {
- value: string
- onChange: (value: string, projectInfo?: JiraProjectInfo) => void
- provider: OAuthProvider
- requiredScopes?: string[]
- label?: string
- disabled?: boolean
- serviceId?: string
- domain: string
- showPreview?: boolean
- onProjectInfoChange?: (projectInfo: JiraProjectInfo | null) => void
- credentialId?: string
- isForeignCredential?: boolean
- workflowId?: string
-}
-
-export function JiraProjectSelector({
- value,
- onChange,
- provider,
- requiredScopes = [],
- label = 'Select Jira project',
- disabled = false,
- serviceId,
- domain,
- showPreview = true,
- onProjectInfoChange,
- credentialId,
- isForeignCredential = false,
- workflowId,
-}: JiraProjectSelectorProps) {
- const [open, setOpen] = useState(false)
- const [credentials, setCredentials] = useState
([])
- const [projects, setProjects] = useState([])
- const [selectedCredentialId, setSelectedCredentialId] = useState(credentialId || '')
- const [selectedProjectId, setSelectedProjectId] = useState(value)
- const [selectedProject, setSelectedProject] = useState(null)
- const [isLoading, setIsLoading] = useState(false)
- const [showOAuthModal, setShowOAuthModal] = useState(false)
- const initialFetchRef = useRef(false)
- const [error, setError] = useState(null)
- const [cloudId, setCloudId] = useState(null)
-
- // Get cached display name
- const cachedProjectName = useDisplayNamesStore(
- useCallback(
- (state) => {
- const effectiveCredentialId = credentialId || selectedCredentialId
- if (!effectiveCredentialId || !value) return null
- return state.cache.projects[`jira-${effectiveCredentialId}`]?.[value] || null
- },
- [credentialId, selectedCredentialId, value]
- )
- )
-
- // Handle search with debounce
- const searchTimeoutRef = useRef(null)
-
- const handleSearch = (value: string) => {
- // Clear any existing timeout
- if (searchTimeoutRef.current) {
- clearTimeout(searchTimeoutRef.current)
- }
-
- // Set a new timeout
- searchTimeoutRef.current = setTimeout(() => {
- if (value.length >= 1) {
- fetchProjects(value)
- } else {
- fetchProjects() // Fetch all projects if no search term
- }
- }, 500) // 500ms debounce
- }
-
- // Clean up the timeout on unmount
- useEffect(() => {
- return () => {
- if (searchTimeoutRef.current) {
- clearTimeout(searchTimeoutRef.current)
- }
- }
- }, [])
-
- // Determine the appropriate service ID based on provider and scopes
- const getServiceId = (): string => {
- if (serviceId) return serviceId
- return getServiceIdFromScopes(provider, requiredScopes)
- }
-
- // Determine the appropriate provider ID based on service and scopes (stabilized)
- const providerId = useMemo(() => {
- const effectiveServiceId = getServiceId()
- return getProviderIdFromServiceId(effectiveServiceId)
- }, [serviceId, provider, requiredScopes])
-
- // Fetch available credentials for this provider
- const fetchCredentials = useCallback(async () => {
- if (!providerId) return
- setIsLoading(true)
- try {
- const response = await fetch(`/api/auth/oauth/credentials?provider=${providerId}`)
-
- if (response.ok) {
- const data = await response.json()
- setCredentials(data.credentials)
- // Do not auto-select credentials. Only use the credentialId provided by the parent.
- }
- } catch (error) {
- logger.error('Error fetching credentials:', error)
- } finally {
- setIsLoading(false)
- }
- }, [providerId])
-
- // Fetch detailed project information
- const fetchProjectInfo = useCallback(
- async (projectId: string) => {
- if (!selectedCredentialId || !domain || !projectId) return
-
- setIsLoading(true)
- setError(null)
-
- try {
- // Get the access token from the selected credential
- const tokenResponse = await fetch('/api/auth/oauth/token', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({
- credentialId: selectedCredentialId,
- workflowId,
- }),
- })
-
- if (!tokenResponse.ok) {
- const errorData = await tokenResponse.json()
- logger.error('Access token error:', errorData)
- setError('Authentication failed. Please reconnect your Jira account.')
- return
- }
-
- const tokenData = await tokenResponse.json()
- const accessToken = tokenData.accessToken
-
- if (!accessToken) {
- logger.error('No access token returned')
- setError('Authentication failed. Please reconnect your Jira account.')
- return
- }
-
- // Use POST /api/tools/jira/projects to fetch a single project by id
- const response = await fetch(`/api/tools/jira/projects`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ domain, accessToken, projectId, cloudId }),
- })
-
- if (!response.ok) {
- const errorData = await response.json()
- logger.error('Jira API error:', errorData)
- throw new Error(errorData.error || 'Failed to fetch project details')
- }
-
- const json = await response.json()
- const projectInfo = json?.project
- const newCloudId = json?.cloudId
-
- if (newCloudId) {
- setCloudId(newCloudId)
- }
-
- if (projectInfo) {
- setSelectedProject(projectInfo)
- onProjectInfoChange?.(projectInfo)
- } else {
- setSelectedProject(null)
- onProjectInfoChange?.(null)
- }
- } catch (error) {
- logger.error('Error fetching project details:', error)
- setError((error as Error).message)
- } finally {
- setIsLoading(false)
- }
- },
- [selectedCredentialId, domain, onProjectInfoChange, cloudId]
- )
-
- // Fetch projects from Jira
- const fetchProjects = useCallback(
- async (searchQuery?: string) => {
- if (!selectedCredentialId || !domain) return
-
- // Validate domain format
- const trimmedDomain = domain.trim().toLowerCase()
- if (!trimmedDomain.includes('.')) {
- setError(
- 'Invalid domain format. Please provide the full domain (e.g., your-site.atlassian.net)'
- )
- setProjects([])
- setIsLoading(false)
- return
- }
-
- setIsLoading(true)
- setError(null)
-
- try {
- // Get the access token from the selected credential
- const tokenResponse = await fetch('/api/auth/oauth/token', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({
- credentialId: selectedCredentialId,
- workflowId,
- }),
- })
-
- if (!tokenResponse.ok) {
- const errorData = await tokenResponse.json()
- logger.error('Access token error:', errorData)
- setError('Authentication failed. Please reconnect your Jira account.')
- setIsLoading(false)
- return
- }
-
- const tokenData = await tokenResponse.json()
- const accessToken = tokenData.accessToken
-
- if (!accessToken) {
- logger.error('No access token returned')
- setError('Authentication failed. Please reconnect your Jira account.')
- setIsLoading(false)
- return
- }
-
- // Build query parameters for the projects endpoint
- const queryParams = new URLSearchParams({
- domain,
- accessToken,
- ...(searchQuery && { query: searchQuery }),
- ...(cloudId && { cloudId }),
- })
-
- // Use the GET endpoint for project search
- const response = await fetch(`/api/tools/jira/projects?${queryParams.toString()}`)
-
- if (!response.ok) {
- const errorData = await response.json()
- logger.error('Jira API error:', errorData)
- throw new Error(errorData.error || 'Failed to fetch projects')
- }
-
- const data = await response.json()
-
- if (data.cloudId) {
- setCloudId(data.cloudId)
- }
-
- // Process the projects results
- const foundProjects = data.projects || []
- logger.info(`Received ${foundProjects.length} projects from API`)
- setProjects(foundProjects)
-
- // Cache project names in display names store
- if (selectedCredentialId && foundProjects.length > 0) {
- const projectMap = foundProjects.reduce(
- (acc: Record, proj: JiraProjectInfo) => {
- acc[proj.id] = proj.name
- return acc
- },
- {}
- )
- useDisplayNamesStore
- .getState()
- .setDisplayNames('projects', `jira-${selectedCredentialId}`, projectMap)
- }
-
- // If we have a selected project ID, find the project info
- if (selectedProjectId) {
- const projectInfo = foundProjects.find(
- (project: JiraProjectInfo) => project.id === selectedProjectId
- )
- if (projectInfo) {
- setSelectedProject(projectInfo)
- onProjectInfoChange?.(projectInfo)
- } else if (!searchQuery && selectedProjectId) {
- // If we can't find the project in the list, try to fetch it directly
- fetchProjectInfo(selectedProjectId)
- }
- }
- } catch (error) {
- logger.error('Error fetching projects:', error)
- setError((error as Error).message)
- setProjects([])
- } finally {
- setIsLoading(false)
- }
- },
- [
- selectedCredentialId,
- domain,
- selectedProjectId,
- onProjectInfoChange,
- fetchProjectInfo,
- cloudId,
- ]
- )
-
- // Fetch credentials list when dropdown opens (for account switching UI), not on mount
- useEffect(() => {
- if (open) {
- fetchCredentials()
- }
- }, [open, fetchCredentials])
-
- // Keep local credential state in sync with persisted credential
- useEffect(() => {
- if (credentialId && credentialId !== selectedCredentialId) {
- setSelectedCredentialId(credentialId)
- }
- }, [credentialId, selectedCredentialId])
-
- // Keep internal selectedProjectId in sync with the value prop
- useEffect(() => {
- if (value !== selectedProjectId) {
- setSelectedProjectId(value)
- }
- }, [value, selectedProjectId])
-
- // Clear callback when value is cleared
- useEffect(() => {
- if (!value) {
- setSelectedProject(null)
- onProjectInfoChange?.(null)
- }
- }, [value, onProjectInfoChange])
-
- // Fetch project info on mount if we have a value but no selectedProject state
- useEffect(() => {
- if (value && selectedCredentialId && domain && !selectedProject) {
- fetchProjectInfo(value)
- }
- }, [value, selectedCredentialId, domain, selectedProject, fetchProjectInfo])
-
- // Handle open change
- const handleOpenChange = (isOpen: boolean) => {
- setOpen(isOpen)
- // Only fetch projects when a credential is present; otherwise, do nothing
- if (isOpen && selectedCredentialId && domain && domain.includes('.')) {
- fetchProjects('')
- }
- }
-
- // Handle project selection
- const handleSelectProject = (project: JiraProjectInfo) => {
- setSelectedProjectId(project.id)
- setSelectedProject(project)
- onChange(project.id, project)
- onProjectInfoChange?.(project)
- setOpen(false)
- }
-
- // Handle adding a new credential
- const handleAddCredential = () => {
- // Show the OAuth modal
- setShowOAuthModal(true)
- setOpen(false)
- }
-
- // Clear selection
- const handleClearSelection = () => {
- setSelectedProjectId('')
- setSelectedProject(null)
- setError(null)
- onChange('', undefined)
- onProjectInfoChange?.(null)
- }
-
- return (
- <>
-
-
-
-
-
- {!isForeignCredential && (
-
- {selectedCredentialId && credentials.length > 0 && (
-
-
-
-
- {credentials.find((cred) => cred.id === selectedCredentialId)?.name ||
- 'Unknown'}
-
-
- {credentials.length > 1 && (
-
- )}
-
- )}
-
-
-
-
-
- {isLoading ? (
-
-
- Loading projects...
-
- ) : error ? (
-
- ) : credentials.length === 0 ? (
-
-
No accounts connected.
-
- Connect a Jira account to continue.
-
-
- ) : (
-
-
No projects found.
-
- Try a different search or account.
-
-
- )}
-
-
- {/* Account selection - only show if we have multiple accounts */}
- {credentials.length > 1 && (
-
-
- Switch Account
-
- {credentials.map((cred) => (
- setSelectedCredentialId(cred.id)}
- >
-
-
- {cred.name}
-
- {cred.id === selectedCredentialId && (
-
- )}
-
- ))}
-
- )}
-
- {/* Projects list */}
- {projects.length > 0 && (
-
-
- Projects
-
- {projects.map((project) => (
- handleSelectProject(project)}
- >
-
- {project.avatarUrl ? (
-

- ) : (
-
- )}
-
{project.name}
-
- {project.id === selectedProjectId && (
-
- )}
-
- ))}
-
- )}
-
- {/* Connect account option - only show if no credentials */}
- {credentials.length === 0 && (
-
-
-
-
- Connect Jira account
-
-
-
- )}
-
-
-
- )}
-
-
- {/* Project preview */}
- {showPreview && selectedProject && (
-
-
-
-
-
-
- {selectedProject.avatarUrl ? (
-

- ) : (
-
- )}
-
-
-
-
- )}
-
-
- {showOAuthModal && (
- setShowOAuthModal(false)}
- provider={provider}
- toolName='Jira'
- requiredScopes={requiredScopes}
- serviceId={getServiceId()}
- />
- )}
- >
- )
-}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/project-selector/components/linear-project-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/project-selector/components/linear-project-selector.tsx
deleted file mode 100644
index 21fbdaada9f..00000000000
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/project-selector/components/linear-project-selector.tsx
+++ /dev/null
@@ -1,196 +0,0 @@
-import { useCallback, useEffect, useState } from 'react'
-import { Check, ChevronDown, RefreshCw } from 'lucide-react'
-import { LinearIcon } from '@/components/icons'
-import { Button } from '@/components/ui/button'
-import {
- Command,
- CommandEmpty,
- CommandGroup,
- CommandInput,
- CommandItem,
- CommandList,
-} from '@/components/ui/command'
-import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
-import { useDisplayNamesStore } from '@/stores/display-names/store'
-
-export interface LinearProjectInfo {
- id: string
- name: string
-}
-
-interface LinearProjectSelectorProps {
- value: string
- onChange: (projectId: string, projectInfo?: LinearProjectInfo) => void
- credential: string
- teamId: string
- label?: string
- disabled?: boolean
- workflowId?: string
-}
-
-export function LinearProjectSelector({
- value,
- onChange,
- credential,
- teamId,
- label = 'Select Linear project',
- disabled = false,
- workflowId,
-}: LinearProjectSelectorProps) {
- const [projects, setProjects] = useState([])
- const [loading, setLoading] = useState(false)
- const [error, setError] = useState(null)
- const [open, setOpen] = useState(false)
-
- // Get cached display name
- const cachedProjectName = useDisplayNamesStore(
- useCallback(
- (state) => {
- if (!credential || !value) return null
- return state.cache.projects[`linear-${credential}`]?.[value] || null
- },
- [credential, value]
- )
- )
-
- useEffect(() => {
- if (!credential || !teamId) return
- const controller = new AbortController()
- setLoading(true)
- setError(null)
-
- fetch('/api/tools/linear/projects', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ credential, teamId, workflowId }),
- signal: controller.signal,
- })
- .then(async (res) => {
- if (!res.ok) {
- const errorText = await res.text()
- throw new Error(`HTTP error! status: ${res.status} - ${errorText}`)
- }
- return res.json()
- })
- .then((data) => {
- if (data.error) {
- setError(data.error)
- setProjects([])
- } else {
- setProjects(data.projects)
-
- // Cache project names in display names store
- if (credential && data.projects) {
- const projectMap = data.projects.reduce(
- (acc: Record, proj: LinearProjectInfo) => {
- acc[proj.id] = proj.name
- return acc
- },
- {}
- )
- useDisplayNamesStore
- .getState()
- .setDisplayNames('projects', `linear-${credential}`, projectMap)
- }
- }
- })
- .catch((err) => {
- if (err.name === 'AbortError') return
- setError(err.message)
- setProjects([])
- })
- .finally(() => setLoading(false))
- return () => controller.abort()
- }, [credential, teamId, value, workflowId])
-
- const handleSelectProject = (project: LinearProjectInfo) => {
- onChange(project.id, project)
- setOpen(false)
- }
-
- const handleOpenChange = (isOpen: boolean) => {
- setOpen(isOpen)
- }
-
- return (
-
-
-
-
-
-
-
-
-
- {loading ? (
-
-
- Loading projects...
-
- ) : error ? (
-
- ) : !credential || !teamId ? (
-
-
Missing credentials or team
-
- Please configure Linear credentials and select a team.
-
-
- ) : (
-
-
No projects found
-
- No projects available for the selected team.
-
-
- )}
-
-
- {projects.length > 0 && (
-
-
- Projects
-
- {projects.map((project) => (
- handleSelectProject(project)}
- className='cursor-pointer'
- >
-
-
- {project.name}
-
- {project.id === value && }
-
- ))}
-
- )}
-
-
-
-
- )
-}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/project-selector/components/linear-team-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/project-selector/components/linear-team-selector.tsx
deleted file mode 100644
index e4724767004..00000000000
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/project-selector/components/linear-team-selector.tsx
+++ /dev/null
@@ -1,190 +0,0 @@
-import { useCallback, useEffect, useState } from 'react'
-import { Check, ChevronDown, RefreshCw } from 'lucide-react'
-import { LinearIcon } from '@/components/icons'
-import { Button } from '@/components/ui/button'
-import {
- Command,
- CommandEmpty,
- CommandGroup,
- CommandInput,
- CommandItem,
- CommandList,
-} from '@/components/ui/command'
-import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
-import { useDisplayNamesStore } from '@/stores/display-names/store'
-
-export interface LinearTeamInfo {
- id: string
- name: string
-}
-
-interface LinearTeamSelectorProps {
- value: string
- onChange: (teamId: string, teamInfo?: LinearTeamInfo) => void
- credential: string
- label?: string
- disabled?: boolean
- workflowId?: string
- showPreview?: boolean
-}
-
-export function LinearTeamSelector({
- value,
- onChange,
- credential,
- label = 'Select Linear team',
- disabled = false,
- workflowId,
-}: LinearTeamSelectorProps) {
- const [teams, setTeams] = useState([])
- const [loading, setLoading] = useState(false)
- const [error, setError] = useState(null)
- const [open, setOpen] = useState(false)
-
- // Get cached display name
- const cachedTeamName = useDisplayNamesStore(
- useCallback(
- (state) => {
- if (!credential || !value) return null
- return state.cache.projects[`linear-${credential}`]?.[value] || null
- },
- [credential, value]
- )
- )
-
- useEffect(() => {
- if (!credential) return
- const controller = new AbortController()
- setLoading(true)
- setError(null)
-
- fetch('/api/tools/linear/teams', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ credential, workflowId }),
- signal: controller.signal,
- })
- .then((res) => {
- if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`)
- return res.json()
- })
- .then((data) => {
- if (data.error) {
- setError(data.error)
- setTeams([])
- } else {
- setTeams(data.teams)
-
- // Cache team names in display names store
- if (credential && data.teams) {
- const teamMap = data.teams.reduce(
- (acc: Record, team: LinearTeamInfo) => {
- acc[team.id] = team.name
- return acc
- },
- {}
- )
- useDisplayNamesStore
- .getState()
- .setDisplayNames('projects', `linear-${credential}`, teamMap)
- }
- }
- })
- .catch((err) => {
- if (err.name === 'AbortError') return
- setError(err.message)
- setTeams([])
- })
- .finally(() => setLoading(false))
- return () => controller.abort()
- }, [credential, value, workflowId])
-
- const handleSelectTeam = (team: LinearTeamInfo) => {
- onChange(team.id, team)
- setOpen(false)
- }
-
- const handleOpenChange = (isOpen: boolean) => {
- setOpen(isOpen)
- }
-
- return (
-
-
-
-
-
-
-
-
-
- {loading ? (
-
-
- Loading teams...
-
- ) : error ? (
-
- ) : !credential ? (
-
-
Missing credentials
-
- Please configure Linear credentials.
-
-
- ) : (
-
-
No teams found
-
- No teams available for this Linear account.
-
-
- )}
-
-
- {teams.length > 0 && (
-
- Teams
- {teams.map((team) => (
- handleSelectTeam(team)}
- className='cursor-pointer'
- >
-
-
- {team.name}
-
- {team.id === value && }
-
- ))}
-
- )}
-
-
-
-
- )
-}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/project-selector/project-selector-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/project-selector/project-selector-input.tsx
index 89ce27162fe..25f16e2f148 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/project-selector/project-selector-input.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/project-selector/project-selector-input.tsx
@@ -1,23 +1,14 @@
'use client'
-import { useEffect, useState } from 'react'
+import { useEffect, useMemo, useState } from 'react'
+import { useParams } from 'next/navigation'
import { Tooltip } from '@/components/emcn'
-import {
- type JiraProjectInfo,
- JiraProjectSelector,
-} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/project-selector/components/jira-project-selector'
-import {
- type LinearProjectInfo,
- LinearProjectSelector,
-} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/project-selector/components/linear-project-selector'
-import {
- type LinearTeamInfo,
- LinearTeamSelector,
-} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/project-selector/components/linear-team-selector'
+import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-foreign-credential'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-value'
import type { SubBlockConfig } from '@/blocks/types'
+import { resolveSelectorForSubBlock } from '@/hooks/selectors/resolution'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -41,10 +32,10 @@ export function ProjectSelectorInput({
previewContextValues,
}: ProjectSelectorInputProps) {
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
+ const params = useParams()
const [selectedProjectId, setSelectedProjectId] = useState('')
- const [_projectInfo, setProjectInfo] = useState(null)
// Use the proper hook to get the current value and setter
- const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
+ const [storeValue] = useSubBlockValue(blockId, subBlock.id)
const [connectedCredentialFromStore] = useSubBlockValue(blockId, 'credential')
const [linearTeamIdFromStore] = useSubBlockValue(blockId, 'teamId')
const [jiraDomainFromStore] = useSubBlockValue(blockId, 'domain')
@@ -60,6 +51,7 @@ export function ProjectSelectorInput({
(connectedCredential as string) || ''
)
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId) as string | null
+ const workflowIdFromUrl = (params?.workflowId as string) || activeWorkflowId || ''
const { finalDisabled } = useDependsOnGate(blockId, subBlock, {
disabled,
isPreview,
@@ -87,91 +79,58 @@ export function ProjectSelectorInput({
}
}, [isPreview, previewValue, storeValue])
- // Handle project selection
- const handleProjectChange = (
- projectId: string,
- info?: JiraProjectInfo | LinearTeamInfo | LinearProjectInfo
- ) => {
- setSelectedProjectId(projectId)
- setProjectInfo(info || null)
- setStoreValue(projectId)
+ const selectorResolution = useMemo(() => {
+ return resolveSelectorForSubBlock(subBlock, {
+ workflowId: workflowIdFromUrl || undefined,
+ credentialId: (isLinear ? linearCredential : jiraCredential) as string | undefined,
+ domain,
+ teamId: (linearTeamId as string) || undefined,
+ })
+ }, [
+ subBlock,
+ workflowIdFromUrl,
+ isLinear,
+ linearCredential,
+ jiraCredential,
+ domain,
+ linearTeamId,
+ ])
- onProjectSelect?.(projectId)
- }
-
- // Discord no longer uses a server selector; fall through to other providers
+ const missingCredential = !selectorResolution?.context.credentialId
- // Render Linear team/project selector if provider is linear
- if (isLinear) {
- return (
-
-
-
- {subBlock.id === 'teamId' ? (
- {
- handleProjectChange(teamId, teamInfo)
- }}
- credential={(linearCredential as string) || ''}
- label={subBlock.placeholder || 'Select Linear team'}
- disabled={finalDisabled}
- showPreview={true}
- workflowId={activeWorkflowId || ''}
- />
- ) : (
- (() => {
- const credential = (linearCredential as string) || ''
- const teamId = (linearTeamId as string) || ''
- const isDisabled = finalDisabled
- return (
- {
- handleProjectChange(projectId, projectInfo)
- }}
- credential={credential}
- teamId={teamId}
- label={subBlock.placeholder || 'Select Linear project'}
- disabled={isDisabled}
- workflowId={activeWorkflowId || ''}
- />
- )
- })()
- )}
-
-
- {!(linearCredential as string) && (
-
- Please select a Linear account first
-
- )}
-
- )
+ const handleChange = (value: string) => {
+ setSelectedProjectId(value)
+ onProjectSelect?.(value)
}
- // Default to Jira project selector
return (
-
+ {selectorResolution?.key ? (
+
+ ) : (
+
+ Project selector not supported for provider: {subBlock.provider || 'unknown'}
+
+ )}
+ {missingCredential && (
+
+ Please select an account first
+
+ )}
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/selector-combobox/selector-combobox.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/selector-combobox/selector-combobox.tsx
new file mode 100644
index 00000000000..6d662ca34f5
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/selector-combobox/selector-combobox.tsx
@@ -0,0 +1,145 @@
+import type React from 'react'
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { Combobox as EditableCombobox } from '@/components/emcn/components'
+import { SubBlockInputController } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/sub-block-input-controller'
+import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-value'
+import type { SubBlockConfig } from '@/blocks/types'
+import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types'
+import {
+ useSelectorOptionDetail,
+ useSelectorOptionMap,
+ useSelectorOptions,
+} from '@/hooks/selectors/use-selector-query'
+
+interface SelectorComboboxProps {
+ blockId: string
+ subBlock: SubBlockConfig
+ selectorKey: SelectorKey
+ selectorContext: SelectorContext
+ disabled?: boolean
+ isPreview?: boolean
+ previewValue?: string | null
+ placeholder?: string
+ readOnly?: boolean
+ onOptionChange?: (value: string) => void
+ allowSearch?: boolean
+}
+
+export function SelectorCombobox({
+ blockId,
+ subBlock,
+ selectorKey,
+ selectorContext,
+ disabled,
+ isPreview,
+ previewValue,
+ placeholder,
+ readOnly,
+ onOptionChange,
+ allowSearch = true,
+}: SelectorComboboxProps) {
+ const [storeValueRaw, setStoreValue] = useSubBlockValue(
+ blockId,
+ subBlock.id
+ )
+ const storeValue = storeValueRaw ?? undefined
+ const previewedValue = previewValue ?? undefined
+ const activeValue: string | undefined = isPreview ? previewedValue : storeValue
+ const [searchTerm, setSearchTerm] = useState('')
+ const [isEditing, setIsEditing] = useState(false)
+ const {
+ data: options = [],
+ isLoading,
+ error,
+ } = useSelectorOptions(selectorKey, {
+ context: selectorContext,
+ search: allowSearch ? searchTerm : undefined,
+ })
+ const { data: detailOption } = useSelectorOptionDetail(selectorKey, {
+ context: selectorContext,
+ detailId: activeValue,
+ })
+ const optionMap = useSelectorOptionMap(options, detailOption ?? undefined)
+ const selectedLabel = activeValue ? (optionMap.get(activeValue)?.label ?? activeValue) : ''
+ const [inputValue, setInputValue] = useState(selectedLabel)
+ const previousActiveValue = useRef(activeValue)
+
+ useEffect(() => {
+ if (previousActiveValue.current !== activeValue) {
+ previousActiveValue.current = activeValue
+ setIsEditing(false)
+ }
+ }, [activeValue])
+
+ useEffect(() => {
+ if (!allowSearch) return
+ if (!isEditing) {
+ setInputValue(selectedLabel)
+ }
+ }, [selectedLabel, allowSearch, isEditing])
+
+ const comboboxOptions = useMemo(
+ () =>
+ Array.from(optionMap.values()).map((option) => ({
+ label: option.label,
+ value: option.id,
+ })),
+ [optionMap]
+ )
+
+ const handleSelection = useCallback(
+ (value: string) => {
+ if (readOnly || disabled) return
+ setStoreValue(value)
+ setIsEditing(false)
+ onOptionChange?.(value)
+ },
+ [setStoreValue, onOptionChange, readOnly, disabled]
+ )
+
+ return (
+
+
+ {({ ref, onDrop, onDragOver }) => (
+ {
+ const matched = optionMap.get(newValue)
+ if (matched) {
+ setInputValue(matched.label)
+ setIsEditing(false)
+ handleSelection(matched.id)
+ return
+ }
+ if (allowSearch) {
+ setInputValue(newValue)
+ setIsEditing(true)
+ setSearchTerm(newValue)
+ }
+ }}
+ placeholder={placeholder || subBlock.placeholder || 'Select an option'}
+ disabled={disabled || readOnly}
+ editable={allowSearch}
+ filterOptions={allowSearch}
+ inputRef={ref as React.RefObject}
+ inputProps={{
+ onDrop: onDrop as (e: React.DragEvent) => void,
+ onDragOver: onDragOver as (e: React.DragEvent) => void,
+ }}
+ isLoading={isLoading}
+ error={error instanceof Error ? error.message : null}
+ />
+ )}
+
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tool-input/components/mcp-server-modal/mcp-server-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tool-input/components/mcp-server-modal/mcp-server-modal.tsx
deleted file mode 100644
index bb192d4d4ed..00000000000
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tool-input/components/mcp-server-modal/mcp-server-modal.tsx
+++ /dev/null
@@ -1,588 +0,0 @@
-'use client'
-
-import { useCallback, useRef, useState } from 'react'
-import { X } from 'lucide-react'
-import { useParams } from 'next/navigation'
-import { Button } from '@/components/ui/button'
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
-} from '@/components/ui/dialog'
-import { Input } from '@/components/ui/input'
-import { Label } from '@/components/ui/label'
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from '@/components/ui/select'
-import { createLogger } from '@/lib/logs/console/logger'
-import type { McpTransport } from '@/lib/mcp/types'
-import {
- checkEnvVarTrigger,
- EnvVarDropdown,
-} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/env-var-dropdown'
-import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/formatted-text'
-import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
-import { useCreateMcpServer } from '@/hooks/queries/mcp'
-import { useMcpServerTest } from '@/hooks/use-mcp-server-test'
-
-const logger = createLogger('McpServerModal')
-
-interface McpServerModalProps {
- open: boolean
- onOpenChange: (open: boolean) => void
- onServerCreated?: () => void
- blockId: string
-}
-
-interface McpServerFormData {
- name: string
- transport: McpTransport
- url?: string
- headers?: Record
-}
-
-export function McpServerModal({
- open,
- onOpenChange,
- onServerCreated,
- blockId,
-}: McpServerModalProps) {
- const params = useParams()
- const workspaceId = params.workspaceId as string
- const [formData, setFormData] = useState({
- name: '',
- transport: 'streamable-http',
- url: '',
- headers: { '': '' },
- })
- const createServerMutation = useCreateMcpServer()
- const [localError, setLocalError] = useState(null)
-
- // MCP server testing
- const { testResult, isTestingConnection, testConnection, clearTestResult } = useMcpServerTest()
-
- // Environment variable dropdown state
- const [showEnvVars, setShowEnvVars] = useState(false)
- const [searchTerm, setSearchTerm] = useState('')
- const [cursorPosition, setCursorPosition] = useState(0)
- const [activeInputField, setActiveInputField] = useState<
- 'url' | 'header-key' | 'header-value' | null
- >(null)
- const [activeHeaderIndex, setActiveHeaderIndex] = useState(null)
- const urlInputRef = useRef(null)
- const [urlScrollLeft, setUrlScrollLeft] = useState(0)
- const [headerScrollLeft, setHeaderScrollLeft] = useState>({})
-
- const error = localError || createServerMutation.error?.message
-
- const resetForm = () => {
- setFormData({
- name: '',
- transport: 'streamable-http',
- url: '',
- headers: { '': '' },
- })
- setLocalError(null)
- createServerMutation.reset()
- setShowEnvVars(false)
- setActiveInputField(null)
- setActiveHeaderIndex(null)
- clearTestResult()
- }
-
- // Handle environment variable selection
- const handleEnvVarSelect = useCallback(
- (newValue: string) => {
- if (activeInputField === 'url') {
- setFormData((prev) => ({ ...prev, url: newValue }))
- } else if (activeInputField === 'header-key' && activeHeaderIndex !== null) {
- const headerEntries = Object.entries(formData.headers || {})
- const [oldKey, value] = headerEntries[activeHeaderIndex]
- const newHeaders = { ...formData.headers }
- delete newHeaders[oldKey]
- newHeaders[newValue.replace(/[{}]/g, '')] = value
- setFormData((prev) => ({ ...prev, headers: newHeaders }))
- } else if (activeInputField === 'header-value' && activeHeaderIndex !== null) {
- const headerEntries = Object.entries(formData.headers || {})
- const [key] = headerEntries[activeHeaderIndex]
- setFormData((prev) => ({
- ...prev,
- headers: { ...prev.headers, [key]: newValue },
- }))
- }
- setShowEnvVars(false)
- setActiveInputField(null)
- setActiveHeaderIndex(null)
- },
- [activeInputField, activeHeaderIndex, formData.headers]
- )
-
- // Handle input change with env var detection
- const handleInputChange = useCallback(
- (field: 'url' | 'header-key' | 'header-value', value: string, headerIndex?: number) => {
- const input = document.activeElement as HTMLInputElement
- const pos = input?.selectionStart || 0
-
- setCursorPosition(pos)
-
- // Clear test result when any field changes
- if (testResult) {
- clearTestResult()
- }
-
- // Check if we should show the environment variables dropdown
- const envVarTrigger = checkEnvVarTrigger(value, pos)
- setShowEnvVars(envVarTrigger.show)
- setSearchTerm(envVarTrigger.show ? envVarTrigger.searchTerm : '')
-
- if (envVarTrigger.show) {
- setActiveInputField(field)
- setActiveHeaderIndex(headerIndex ?? null)
- } else {
- setActiveInputField(null)
- setActiveHeaderIndex(null)
- }
-
- // Update form data
- if (field === 'url') {
- setFormData((prev) => ({ ...prev, url: value }))
- } else if (field === 'header-key' && headerIndex !== undefined) {
- const headerEntries = Object.entries(formData.headers || {})
- const [oldKey, headerValue] = headerEntries[headerIndex]
- const newHeaders = { ...formData.headers }
- delete newHeaders[oldKey]
- newHeaders[value] = headerValue
-
- // Add a new empty header row if this is the last row and both key and value have content
- const isLastRow = headerIndex === headerEntries.length - 1
- const hasContent = value.trim() !== '' && headerValue.trim() !== ''
- if (isLastRow && hasContent) {
- newHeaders[''] = ''
- }
-
- setFormData((prev) => ({ ...prev, headers: newHeaders }))
- } else if (field === 'header-value' && headerIndex !== undefined) {
- const headerEntries = Object.entries(formData.headers || {})
- const [key] = headerEntries[headerIndex]
- const newHeaders = { ...formData.headers, [key]: value }
-
- // Add a new empty header row if this is the last row and both key and value have content
- const isLastRow = headerIndex === headerEntries.length - 1
- const hasContent = key.trim() !== '' && value.trim() !== ''
- if (isLastRow && hasContent) {
- newHeaders[''] = ''
- }
-
- setFormData((prev) => ({ ...prev, headers: newHeaders }))
- }
- },
- [formData.headers, testResult, clearTestResult]
- )
-
- const handleTestConnection = useCallback(async () => {
- if (!formData.name.trim() || !formData.url?.trim()) return
-
- await testConnection({
- name: formData.name,
- transport: formData.transport,
- url: formData.url,
- headers: formData.headers,
- timeout: 30000,
- workspaceId,
- })
- }, [formData, testConnection, workspaceId])
-
- const handleSubmit = useCallback(async () => {
- if (!formData.name.trim()) {
- setLocalError('Server name is required')
- return
- }
-
- if (!formData.url?.trim()) {
- setLocalError('Server URL is required for HTTP/SSE transport')
- return
- }
-
- setLocalError(null)
- createServerMutation.reset()
-
- try {
- // If no test has been done, test first
- if (!testResult) {
- const result = await testConnection({
- name: formData.name,
- transport: formData.transport,
- url: formData.url,
- headers: formData.headers,
- timeout: 30000,
- workspaceId,
- })
-
- // If test fails, don't proceed
- if (!result.success) {
- return
- }
- }
-
- // If we have a failed test result, don't proceed
- if (testResult && !testResult.success) {
- return
- }
-
- // Filter out empty headers
- const cleanHeaders = Object.fromEntries(
- Object.entries(formData.headers || {}).filter(
- ([key, value]) => key.trim() !== '' && value.trim() !== ''
- )
- )
-
- await createServerMutation.mutateAsync({
- workspaceId,
- config: {
- name: formData.name.trim(),
- transport: formData.transport,
- url: formData.url,
- timeout: 30000,
- headers: cleanHeaders,
- enabled: true,
- },
- })
-
- logger.info(`Added MCP server: ${formData.name}`)
-
- // Close modal and reset form immediately after successful creation
- resetForm()
- onOpenChange(false)
- onServerCreated?.()
- } catch (error) {
- logger.error('Failed to add MCP server:', error)
- setLocalError(error instanceof Error ? error.message : 'Failed to add MCP server')
- }
- }, [
- formData,
- testResult,
- testConnection,
- onOpenChange,
- onServerCreated,
- createServerMutation,
- workspaceId,
- ])
-
- const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
-
- return (
-
- )
-}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tool-input/components/tool-credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tool-input/components/tool-credential-selector.tsx
index f014b092354..7dfa8145688 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tool-input/components/tool-credential-selector.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tool-input/components/tool-credential-selector.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useState } from 'react'
+import { useEffect, useMemo, useState } from 'react'
import { Check, ChevronDown, ExternalLink, Plus, RefreshCw } from 'lucide-react'
import { Button } from '@/components/emcn/components/button/button'
import {
@@ -11,7 +11,6 @@ import {
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { createLogger } from '@/lib/logs/console/logger'
import {
- type Credential,
getCanonicalScopesForProvider,
getProviderIdFromServiceId,
OAUTH_PROVIDERS,
@@ -20,8 +19,8 @@ import {
parseProvider,
} from '@/lib/oauth'
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
+import { useOAuthCredentialDetail, useOAuthCredentials } from '@/hooks/queries/oauth-credentials'
import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status'
-import { useDisplayNamesStore } from '@/stores/display-names/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('ToolCredentialSelector')
@@ -70,8 +69,6 @@ export function ToolCredentialSelector({
disabled = false,
}: ToolCredentialSelectorProps) {
const [open, setOpen] = useState(false)
- const [credentials, setCredentials] = useState([])
- const [isLoading, setIsLoading] = useState(false)
const [showOAuthModal, setShowOAuthModal] = useState(false)
const [selectedId, setSelectedId] = useState('')
const { activeWorkflowId } = useWorkflowRegistry()
@@ -80,80 +77,43 @@ export function ToolCredentialSelector({
setSelectedId(value)
}, [value])
- const fetchCredentials = useCallback(async () => {
- setIsLoading(true)
- try {
- const response = await fetch(`/api/auth/oauth/credentials?provider=${provider}`)
- if (response.ok) {
- const data = await response.json()
- setCredentials(data.credentials || [])
+ const {
+ data: fetchedCredentials = [],
+ isFetching: credentialsLoading,
+ refetch: refetchCredentials,
+ } = useOAuthCredentials(provider, true)
- // Cache credential names for block previews
- if (provider) {
- const credentialMap = (data.credentials || []).reduce(
- (acc: Record, cred: Credential) => {
- acc[cred.id] = cred.name
- return acc
- },
- {}
- )
- useDisplayNamesStore.getState().setDisplayNames('credentials', provider, credentialMap)
- }
+ const shouldFetchDetail =
+ Boolean(value) &&
+ !fetchedCredentials.some((cred) => cred.id === value) &&
+ Boolean(activeWorkflowId)
- if (
- value &&
- !(data.credentials || []).some((cred: Credential) => cred.id === value) &&
- activeWorkflowId
- ) {
- try {
- const metaResp = await fetch(
- `/api/auth/oauth/credentials?credentialId=${value}&workflowId=${activeWorkflowId}`
- )
- if (metaResp.ok) {
- const meta = await metaResp.json()
- if (meta.credentials?.length) {
- const combinedCredentials = [meta.credentials[0], ...(data.credentials || [])]
- setCredentials(combinedCredentials)
+ const { data: collaboratorCredentials = [], isFetching: collaboratorLoading } =
+ useOAuthCredentialDetail(
+ shouldFetchDetail ? value : undefined,
+ activeWorkflowId || undefined,
+ shouldFetchDetail
+ )
- const credentialMap = combinedCredentials.reduce(
- (acc: Record, cred: Credential) => {
- acc[cred.id] = cred.name
- return acc
- },
- {}
- )
- useDisplayNamesStore
- .getState()
- .setDisplayNames('credentials', provider, credentialMap)
- }
- }
- } catch {
- // ignore
- }
- }
- } else {
- logger.error('Error fetching credentials:', { error: await response.text() })
- setCredentials([])
- }
- } catch (error) {
- logger.error('Error fetching credentials:', { error })
- setCredentials([])
- } finally {
- setIsLoading(false)
+ const credentials = useMemo(() => {
+ if (collaboratorCredentials.length === 0) {
+ return fetchedCredentials
}
- }, [provider, value, onChange])
-
- // Fetch credentials on initial mount only
- useEffect(() => {
- fetchCredentials()
- // This effect should only run once on mount
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [])
+ const collaborator = collaboratorCredentials[0]
+ if (!collaborator) {
+ return fetchedCredentials
+ }
+ const alreadyIncluded = fetchedCredentials.some((cred) => cred.id === collaborator.id)
+ if (alreadyIncluded) {
+ return fetchedCredentials
+ }
+ return [collaborator, ...fetchedCredentials]
+ }, [fetchedCredentials, collaboratorCredentials])
useEffect(() => {
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
- fetchCredentials()
+ void refetchCredentials()
}
}
@@ -162,7 +122,7 @@ export function ToolCredentialSelector({
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
- }, [fetchCredentials])
+ }, [refetchCredentials])
const handleSelect = (credentialId: string) => {
setSelectedId(credentialId)
@@ -172,13 +132,13 @@ export function ToolCredentialSelector({
const handleOAuthClose = () => {
setShowOAuthModal(false)
- fetchCredentials()
+ void refetchCredentials()
}
const handleOpenChange = (isOpen: boolean) => {
setOpen(isOpen)
if (isOpen) {
- fetchCredentials()
+ void refetchCredentials()
}
}
@@ -190,7 +150,8 @@ export function ToolCredentialSelector({
const missingRequiredScopes = hasSelection
? getMissingRequiredScopes(selectedCredential, requiredScopes || [])
: []
- const needsUpdate = hasSelection && missingRequiredScopes.length > 0 && !disabled && !isLoading
+ const needsUpdate =
+ hasSelection && missingRequiredScopes.length > 0 && !disabled && !credentialsLoading
return (
<>
@@ -224,7 +185,7 @@ export function ToolCredentialSelector({
- {isLoading ? (
+ {credentialsLoading || collaboratorLoading ? (
Loading...
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/sub-block/components/file-selector/file-selector-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/sub-block/components/file-selector/file-selector-input.tsx
new file mode 100644
index 00000000000..c5961ce468b
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/sub-block/components/file-selector/file-selector-input.tsx
@@ -0,0 +1,211 @@
+'use client'
+
+import { useMemo } from 'react'
+import { useParams } from 'next/navigation'
+import { Tooltip } from '@/components/emcn'
+import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
+import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-depends-on-gate'
+import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-foreign-credential'
+import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-value'
+import type { SubBlockConfig } from '@/blocks/types'
+import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types'
+import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
+import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
+
+interface FileSelectorInputProps {
+ blockId: string
+ subBlock: SubBlockConfig
+ disabled: boolean
+ isPreview?: boolean
+ previewValue?: any | null
+ previewContextValues?: Record
+}
+
+export function FileSelectorInput({
+ blockId,
+ subBlock,
+ disabled,
+ isPreview = false,
+ previewValue,
+ previewContextValues,
+}: FileSelectorInputProps) {
+ const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
+ const { activeWorkflowId } = useWorkflowRegistry()
+ const params = useParams()
+ const workflowIdFromUrl = (params?.workflowId as string) || activeWorkflowId || ''
+
+ const { finalDisabled } = useDependsOnGate(blockId, subBlock, {
+ disabled,
+ isPreview,
+ previewContextValues,
+ })
+
+ const [connectedCredentialFromStore] = useSubBlockValue(blockId, 'credential')
+ const [domainValueFromStore] = useSubBlockValue(blockId, 'domain')
+ const [projectIdValueFromStore] = useSubBlockValue(blockId, 'projectId')
+ const [planIdValueFromStore] = useSubBlockValue(blockId, 'planId')
+ const [teamIdValueFromStore] = useSubBlockValue(blockId, 'teamId')
+
+ const connectedCredential = previewContextValues?.credential ?? connectedCredentialFromStore
+ const domainValue = previewContextValues?.domain ?? domainValueFromStore
+ const projectIdValue = previewContextValues?.projectId ?? projectIdValueFromStore
+ const planIdValue = previewContextValues?.planId ?? planIdValueFromStore
+ const teamIdValue = previewContextValues?.teamId ?? teamIdValueFromStore
+
+ const normalizedCredentialId =
+ typeof connectedCredential === 'string'
+ ? connectedCredential
+ : typeof connectedCredential === 'object' && connectedCredential !== null
+ ? ((connectedCredential as Record).id ?? '')
+ : ''
+
+ const { isForeignCredential } = useForeignCredential(
+ subBlock.provider || subBlock.serviceId || 'google-drive',
+ normalizedCredentialId
+ )
+
+ const selectorResolution = useMemo(() => {
+ return resolveSelector({
+ provider: subBlock.provider || '',
+ serviceId: subBlock.serviceId,
+ mimeType: subBlock.mimeType,
+ credentialId: normalizedCredentialId,
+ workflowId: workflowIdFromUrl,
+ domain: (domainValue as string) || '',
+ projectId: (projectIdValue as string) || '',
+ planId: (planIdValue as string) || '',
+ teamId: (teamIdValue as string) || '',
+ })
+ }, [
+ subBlock.provider,
+ subBlock.serviceId,
+ subBlock.mimeType,
+ normalizedCredentialId,
+ workflowIdFromUrl,
+ domainValue,
+ projectIdValue,
+ planIdValue,
+ teamIdValue,
+ ])
+
+ const missingCredential = !normalizedCredentialId
+ const missingDomain =
+ selectorResolution.key &&
+ (selectorResolution.key === 'confluence.pages' || selectorResolution.key === 'jira.issues') &&
+ !selectorResolution.context.domain
+ const missingProject =
+ selectorResolution.key === 'jira.issues' &&
+ subBlock.dependsOn?.includes('projectId') &&
+ !selectorResolution.context.projectId
+ const missingPlan =
+ selectorResolution.key === 'microsoft.planner' && !selectorResolution.context.planId
+
+ const disabledReason =
+ finalDisabled ||
+ isForeignCredential ||
+ missingCredential ||
+ missingDomain ||
+ missingProject ||
+ missingPlan ||
+ selectorResolution.key === null
+
+ if (selectorResolution.key === null) {
+ return (
+
+
+
+ File selector not supported for provider: {subBlock.provider || subBlock.serviceId}
+
+
+
+ This file selector is not implemented for {subBlock.provider || subBlock.serviceId}
+
+
+ )
+ }
+
+ return (
+ {
+ if (!isPreview) {
+ collaborativeSetSubblockValue(blockId, subBlock.id, value)
+ }
+ }}
+ />
+ )
+}
+
+interface SelectorParams {
+ provider: string
+ serviceId?: string
+ mimeType?: string
+ credentialId: string
+ workflowId: string
+ domain?: string
+ projectId?: string
+ planId?: string
+ teamId?: string
+}
+
+function resolveSelector(params: SelectorParams): {
+ key: SelectorKey | null
+ context: SelectorContext
+ allowSearch: boolean
+} {
+ const baseContext: SelectorContext = {
+ credentialId: params.credentialId,
+ workflowId: params.workflowId,
+ domain: params.domain,
+ projectId: params.projectId,
+ planId: params.planId,
+ teamId: params.teamId,
+ mimeType: params.mimeType,
+ }
+
+ switch (params.provider) {
+ case 'google-calendar':
+ return { key: 'google.calendar', context: baseContext, allowSearch: false }
+ case 'confluence':
+ return { key: 'confluence.pages', context: baseContext, allowSearch: true }
+ case 'jira':
+ return { key: 'jira.issues', context: baseContext, allowSearch: true }
+ case 'microsoft-teams':
+ return { key: 'microsoft.teams', context: baseContext, allowSearch: true }
+ case 'wealthbox':
+ return { key: 'wealthbox.contacts', context: baseContext, allowSearch: true }
+ case 'microsoft-planner':
+ return { key: 'microsoft.planner', context: baseContext, allowSearch: true }
+ case 'microsoft-excel':
+ return { key: 'microsoft.excel', context: baseContext, allowSearch: true }
+ case 'microsoft-word':
+ return { key: 'microsoft.word', context: baseContext, allowSearch: true }
+ case 'google-drive':
+ return { key: 'google.drive', context: baseContext, allowSearch: true }
+ default:
+ break
+ }
+
+ if (params.serviceId === 'onedrive') {
+ const key: SelectorKey = params.mimeType === 'file' ? 'onedrive.files' : 'onedrive.folders'
+ return { key, context: baseContext, allowSearch: true }
+ }
+
+ if (params.serviceId === 'sharepoint') {
+ return { key: 'sharepoint.sites', context: baseContext, allowSearch: true }
+ }
+
+ if (params.serviceId === 'google-drive') {
+ return { key: 'google.drive', context: baseContext, allowSearch: true }
+ }
+
+ return { key: null, context: baseContext, allowSearch: true }
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx
index 68c32e4cabf..f4b8790679c 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx
@@ -13,9 +13,10 @@ import {
useBlockDimensions,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-dimensions'
import { SELECTOR_TYPES_HYDRATION_REQUIRED, type SubBlockConfig } from '@/blocks/types'
+import { useCredentialName } from '@/hooks/queries/oauth-credentials'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
-import { useCredentialDisplay } from '@/hooks/use-credential-display'
-import { useDisplayName } from '@/hooks/use-display-name'
+import { useKnowledgeBaseName } from '@/hooks/use-knowledge-base-name'
+import { useSelectorDisplayName } from '@/hooks/use-selector-display-name'
import { useVariablesStore } from '@/stores/panel/variables/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
@@ -230,9 +231,12 @@ const SubBlockRow = ({
}, {})
}, [getStringValue, subBlock?.dependsOn])
- const { displayName: credentialName } = useCredentialDisplay(
- subBlock?.type === 'oauth-input' && typeof rawValue === 'string' ? rawValue : undefined,
- subBlock?.provider
+ const credentialSourceId =
+ subBlock?.type === 'oauth-input' && typeof rawValue === 'string' ? rawValue : undefined
+ const { displayName: credentialName } = useCredentialName(
+ credentialSourceId,
+ subBlock?.provider,
+ workflowId
)
const credentialId = dependencyValues.credential
@@ -253,17 +257,35 @@ const SubBlockRow = ({
return typeof option === 'string' ? option : option.label
}, [subBlock, rawValue])
- const genericDisplayName = useDisplayName(subBlock, rawValue, {
- workspaceId,
- provider: subBlock?.provider,
+ const domainValue = getStringValue('domain')
+ const teamIdValue = getStringValue('teamId')
+ const projectIdValue = getStringValue('projectId')
+ const planIdValue = getStringValue('planId')
+
+ const { displayName: selectorDisplayName } = useSelectorDisplayName({
+ subBlock,
+ value: rawValue,
+ workflowId,
credentialId: typeof credentialId === 'string' ? credentialId : undefined,
knowledgeBaseId: typeof knowledgeBaseId === 'string' ? knowledgeBaseId : undefined,
- domain: getStringValue('domain'),
- teamId: getStringValue('teamId'),
- projectId: getStringValue('projectId'),
- planId: getStringValue('planId'),
+ domain: domainValue,
+ teamId: teamIdValue,
+ projectId: projectIdValue,
+ planId: planIdValue,
})
+ const knowledgeBaseDisplayName = useKnowledgeBaseName(
+ subBlock?.type === 'knowledge-base-selector' && typeof rawValue === 'string'
+ ? rawValue
+ : undefined
+ )
+
+ const workflowMap = useWorkflowRegistry((state) => state.workflows)
+ const workflowSelectionName =
+ subBlock?.id === 'workflowId' && typeof rawValue === 'string'
+ ? (workflowMap[rawValue]?.name ?? null)
+ : null
+
// Subscribe to variables store to reactively update when variables change
const allVariables = useVariablesStore((state) => state.variables)
@@ -300,7 +322,12 @@ const SubBlockRow = ({
const isSelectorType = subBlock?.type && SELECTOR_TYPES_HYDRATION_REQUIRED.includes(subBlock.type)
const hydratedName =
- credentialName || dropdownLabel || variablesDisplayValue || genericDisplayName
+ credentialName ||
+ dropdownLabel ||
+ variablesDisplayValue ||
+ knowledgeBaseDisplayName ||
+ workflowSelectionName ||
+ selectorDisplayName
const displayValue = maskedValue || hydratedName || (isSelectorType && value ? '-' : value)
return (
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx
index 71eb76a46ca..03ab11d0a20 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx
@@ -434,6 +434,7 @@ const WorkflowContent = React.memo(() => {
activeElement?.hasAttribute('contenteditable')
if (isEditableElement) {
+ event.stopPropagation()
return
}
@@ -1214,16 +1215,29 @@ const WorkflowContent = React.memo(() => {
// Initialize workflow when it exists in registry and isn't active
useEffect(() => {
+ let cancelled = false
const currentId = params.workflowId as string
- if (!currentId || !workflows[currentId]) return
+
+ // Wait for registry to be ready to prevent race conditions
+ // Don't proceed if: no workflowId, registry is loading, or workflow not in registry
+ if (!currentId || isLoading || !workflows[currentId]) return
if (activeWorkflowId !== currentId) {
// Clear diff and set as active
const { clearDiff } = useWorkflowDiffStore.getState()
clearDiff()
- setActiveWorkflow(currentId)
+
+ setActiveWorkflow(currentId).catch((error) => {
+ if (!cancelled) {
+ logger.error(`Failed to set active workflow ${currentId}:`, error)
+ }
+ })
+ }
+
+ return () => {
+ cancelled = true
}
- }, [params.workflowId, workflows, activeWorkflowId, setActiveWorkflow])
+ }, [params.workflowId, workflows, activeWorkflowId, setActiveWorkflow, isLoading])
// Track when workflow is ready for rendering
useEffect(() => {
@@ -1233,11 +1247,15 @@ const WorkflowContent = React.memo(() => {
// 1. We have an active workflow that matches the URL
// 2. The workflow exists in the registry
// 3. Workflows are not currently loading
+ // 4. The workflow store has been initialized (lastSaved exists means state was loaded)
const shouldBeReady =
- activeWorkflowId === currentId && Boolean(workflows[currentId]) && !isLoading
+ activeWorkflowId === currentId &&
+ Boolean(workflows[currentId]) &&
+ !isLoading &&
+ lastSaved !== undefined
setIsWorkflowReady(shouldBeReady)
- }, [activeWorkflowId, params.workflowId, workflows, isLoading])
+ }, [activeWorkflowId, params.workflowId, workflows, isLoading, lastSaved])
// Preload workspace environment - React Query handles caching automatically
useWorkspaceEnvironment(workspaceId)
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/components/usage-limit/usage-limit.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/components/usage-limit/usage-limit.tsx
index 84d25e236f3..d33f01f2c4a 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/components/usage-limit/usage-limit.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/components/usage-limit/usage-limit.tsx
@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import { useUsageLimits } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/hooks'
+import { useUpdateUsageLimit } from '@/hooks/queries/subscription'
const logger = createLogger('UsageLimit')
@@ -42,20 +43,22 @@ export const UsageLimit = forwardRef(
const [isEditing, setIsEditing] = useState(false)
const inputRef = useRef(null)
- // Use centralized usage limits hook
- const { updateLimit, isUpdating } = useUsageLimits({
+ const { updateLimit, isUpdating: isOrgUpdating } = useUsageLimits({
context,
organizationId,
autoRefresh: false, // Don't auto-refresh, we receive values via props
})
+ const updateUsageLimitMutation = useUpdateUsageLimit()
+ const isUpdating =
+ context === 'organization' ? isOrgUpdating : updateUsageLimitMutation.isPending
+
const handleStartEdit = () => {
if (!canEdit) return
setIsEditing(true)
setInputValue(currentLimit.toString())
}
- // Expose startEdit method through ref
useImperativeHandle(
ref,
() => ({
@@ -68,7 +71,6 @@ export const UsageLimit = forwardRef(
setInputValue(currentLimit.toString())
}, [currentLimit])
- // Focus input when entering edit mode
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus()
@@ -76,7 +78,6 @@ export const UsageLimit = forwardRef(
}
}, [isEditing])
- // Clear error after 2 seconds
useEffect(() => {
if (hasError) {
const timer = setTimeout(() => {
@@ -96,11 +97,9 @@ export const UsageLimit = forwardRef(
return
}
- // Check if new limit is below current usage
if (newLimit < currentUsage) {
setHasError(true)
setErrorType('belowUsage')
- // Don't reset input value - let user see what they typed
return
}
@@ -109,20 +108,43 @@ export const UsageLimit = forwardRef(
return
}
- // Use the centralized hook to update the limit
- const result = await updateLimit(newLimit)
+ try {
+ if (context === 'organization') {
+ const result = await updateLimit(newLimit)
+
+ if (result.success) {
+ setInputValue(newLimit.toString())
+ onLimitUpdated?.(newLimit)
+ setIsEditing(false)
+ setErrorType(null)
+ setHasError(false)
+ } else {
+ logger.error('Failed to update usage limit', { error: result.error })
+
+ if (result.error?.includes('below current usage')) {
+ setErrorType('belowUsage')
+ } else {
+ setErrorType('general')
+ }
+
+ setHasError(true)
+ }
+
+ return
+ }
+
+ await updateUsageLimitMutation.mutateAsync({ limit: newLimit })
- if (result.success) {
setInputValue(newLimit.toString())
onLimitUpdated?.(newLimit)
setIsEditing(false)
setErrorType(null)
setHasError(false)
- } else {
- logger.error('Failed to update usage limit', { error: result.error })
+ } catch (err) {
+ logger.error('Failed to update usage limit', { error: err })
- // Check if the error is about being below current usage
- if (result.error?.includes('below current usage')) {
+ const message = err instanceof Error ? err.message : String(err)
+ if (message.includes('below current usage')) {
setErrorType('belowUsage')
} else {
setErrorType('general')
@@ -161,7 +183,6 @@ export const UsageLimit = forwardRef(
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={(e) => {
- // Don't submit if clicking on the button (it will handle submission)
const relatedTarget = e.relatedTarget as HTMLElement
if (relatedTarget?.closest('button')) {
return
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/subscription.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/subscription.tsx
index 274be344bf4..eb535903572 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/subscription.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/subscription.tsx
@@ -169,7 +169,6 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
const canManageWorkspaceKeys = userPermissions.canAdmin
const logger = createLogger('Subscription')
- // React Query hooks for data fetching
const { data: subscriptionData, isLoading: isSubscriptionLoading } = useSubscriptionData()
const { data: usageLimitResponse, isLoading: isUsageLimitLoading } = useUsageLimitData()
const { data: workspaceData, isLoading: isWorkspaceLoading } = useWorkspaceSettings(workspaceId)
@@ -179,7 +178,6 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
const activeOrganization = orgsData?.activeOrganization
const activeOrgId = activeOrganization?.id
- // Fetch organization billing data with React Query
const { data: organizationBillingData, isLoading: isOrgBillingLoading } = useOrganizationBilling(
activeOrgId || ''
)
@@ -187,10 +185,8 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
const [upgradeError, setUpgradeError] = useState<'pro' | 'team' | null>(null)
const usageLimitRef = useRef(null)
- // Combine all loading states
const isLoading = isSubscriptionLoading || isUsageLimitLoading || isWorkspaceLoading
- // Extract subscription status from subscriptionData.data
const subscription = {
isFree: subscriptionData?.data?.plan === 'free' || !subscriptionData?.data?.plan,
isPro: subscriptionData?.data?.plan === 'pro',
@@ -205,28 +201,23 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
seats: subscriptionData?.data?.seats || 1,
}
- // Extract usage data from subscriptionData.data.usage (same source as panel usage indicator)
const usage = {
current: subscriptionData?.data?.usage?.current || 0,
limit: subscriptionData?.data?.usage?.limit || 0,
percentUsed: subscriptionData?.data?.usage?.percentUsed || 0,
}
- // Extract usage limit metadata from usageLimitResponse.data
const usageLimitData = {
currentLimit: usageLimitResponse?.data?.currentLimit || 0,
minimumLimit: usageLimitResponse?.data?.minimumLimit || (subscription.isPro ? 20 : 40),
}
- // Extract billing status
const billingStatus = subscriptionData?.data?.billingBlocked ? 'blocked' : 'ok'
- // Extract workspace settings
const billedAccountUserId = workspaceData?.settings?.workspace?.billedAccountUserId ?? null
const workspaceAdmins =
workspaceData?.permissions?.users?.filter((user: any) => user.permissionType === 'admin') || []
- // Update workspace settings handler
const updateWorkspaceSettings = async (updates: { billedAccountUserId?: string }) => {
if (!workspaceId) return
try {
@@ -240,7 +231,6 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
}
}
- // Auto-clear upgrade error
useEffect(() => {
if (upgradeError) {
const timer = setTimeout(() => {
@@ -250,11 +240,9 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
}
}, [upgradeError])
- // User role and permissions
const userRole = getUserRole(activeOrganization, session?.user?.email)
const isTeamAdmin = ['owner', 'admin'].includes(userRole)
- // Get permissions based on subscription state and user role
const permissions = getSubscriptionPermissions(
{
isFree: subscription.isFree,
@@ -271,7 +259,6 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
}
)
- // Get visible plans based on current subscription
const visiblePlans = getVisiblePlans(
{
isFree: subscription.isFree,
@@ -459,8 +446,8 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
}
context={subscription.isTeam && isTeamAdmin ? 'organization' : 'user'}
organizationId={subscription.isTeam && isTeamAdmin ? activeOrgId : undefined}
- onLimitUpdated={async () => {
- // React Query will automatically refetch when the mutation completes
+ onLimitUpdated={() => {
+ logger.info('Usage limit updated')
}}
/>
) : undefined
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/usage-indicator/usage-indicator.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/usage-indicator/usage-indicator.tsx
index 34dfacee8d8..e314788c21e 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/usage-indicator/usage-indicator.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/usage-indicator/usage-indicator.tsx
@@ -10,6 +10,7 @@ import {
getSubscriptionStatus,
getUsage,
} from '@/lib/subscription/helpers'
+import { isUsageAtLimit, USAGE_PILL_COLORS } from '@/lib/subscription/usage-visualization'
import { useSubscriptionData } from '@/hooks/queries/subscription'
import { MIN_SIDEBAR_WIDTH, useSidebarStore } from '@/stores/sidebar/store'
@@ -116,11 +117,12 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
/**
* Calculate which pills should be filled based on usage percentage.
- * Uses Math.ceil heuristic with dynamic pill count (6-8).
- * This ensures consistent calculation logic while maintaining responsive pill count.
+ * Uses a percentage-based heuristic with dynamic pill count (6-8).
+ * The warning/limit (red) state is derived from shared usage visualization utilities
+ * so it is consistent with other parts of the app (e.g. UsageHeader).
*/
const filledPillsCount = Math.ceil((progressPercentage / 100) * pillCount)
- const isAlmostOut = filledPillsCount === pillCount
+ const isAtLimit = isUsageAtLimit(progressPercentage)
const [isHovered, setIsHovered] = useState(false)
const [wavePosition, setWavePosition] = useState(null)
@@ -286,17 +288,17 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
const isFilled = i < filledPillsCount
const baseColor = isFilled
- ? isBlocked || isAlmostOut
- ? '#ef4444'
- : '#34B5FF'
- : '#414141'
+ ? isBlocked || isAtLimit
+ ? USAGE_PILL_COLORS.AT_LIMIT
+ : USAGE_PILL_COLORS.FILLED
+ : USAGE_PILL_COLORS.UNFILLED
let backgroundColor = baseColor
let backgroundImage: string | undefined
if (isHovered && wavePosition !== null && pillCount > 0 && subscription.isFree) {
- const grayColor = '#414141'
- const activeColor = isAlmostOut ? '#ef4444' : '#34B5FF'
+ const grayColor = USAGE_PILL_COLORS.UNFILLED
+ const activeColor = isAtLimit ? USAGE_PILL_COLORS.AT_LIMIT : USAGE_PILL_COLORS.FILLED
/**
* Single-pass wave: travel from {@link startAnimationIndex} to the end
diff --git a/apps/sim/app/workspace/[workspaceId]/w/page.tsx b/apps/sim/app/workspace/[workspaceId]/w/page.tsx
index 2b806fcc335..9cf0382ca80 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/page.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/page.tsx
@@ -38,12 +38,8 @@ export default function WorkflowsPage() {
// If we have valid workspace workflows, redirect to the first one
if (workspaceWorkflows.length > 0) {
- // Ensure the workflow is set as active before redirecting
- // This prevents the empty canvas issue on first login
const firstWorkflowId = workspaceWorkflows[0]
- setActiveWorkflow(firstWorkflowId).then(() => {
- router.replace(`/workspace/${workspaceId}/w/${firstWorkflowId}`)
- })
+ router.replace(`/workspace/${workspaceId}/w/${firstWorkflowId}`)
}
}, [isLoading, workflows, workspaceId, router, setActiveWorkflow, isError])
diff --git a/apps/sim/background/webhook-execution.ts b/apps/sim/background/webhook-execution.ts
index 14e8ca40e2d..7698616b597 100644
--- a/apps/sim/background/webhook-execution.ts
+++ b/apps/sim/background/webhook-execution.ts
@@ -112,7 +112,9 @@ export async function executeWebhookJob(payload: WebhookExecutionPayload) {
const idempotencyKey = IdempotencyService.createWebhookIdempotencyKey(
payload.webhookId,
- payload.headers
+ payload.headers,
+ payload.body,
+ payload.provider
)
const runOperation = async () => {
diff --git a/apps/sim/executor/handlers/trigger/trigger-handler.ts b/apps/sim/executor/handlers/trigger/trigger-handler.ts
index 85fbffb4ab1..1b53cd8641d 100644
--- a/apps/sim/executor/handlers/trigger/trigger-handler.ts
+++ b/apps/sim/executor/handlers/trigger/trigger-handler.ts
@@ -55,7 +55,7 @@ export class TriggerBlockHandler implements BlockHandler {
}
}
- if (provider === 'microsoftteams') {
+ if (provider === 'microsoft-teams') {
const providerData = (starterOutput as any)[provider] || webhookData[provider] || {}
const payloadSource = providerData?.message?.raw || webhookData.payload || {}
return {
diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.ts b/apps/sim/executor/handlers/workflow/workflow-handler.ts
index 6a6877548e7..994daa9f6b8 100644
--- a/apps/sim/executor/handlers/workflow/workflow-handler.ts
+++ b/apps/sim/executor/handlers/workflow/workflow-handler.ts
@@ -12,6 +12,7 @@ import type {
} from '@/executor/types'
import { buildAPIUrl, buildAuthHeaders } from '@/executor/utils/http'
import { parseJSON } from '@/executor/utils/json'
+import { lazyCleanupInputMapping } from '@/executor/utils/lazy-cleanup'
import { Serializer } from '@/serializer'
import type { SerializedBlock } from '@/serializer/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -86,7 +87,15 @@ export class WorkflowBlockHandler implements BlockHandler {
const normalized = parseJSON(inputs.inputMapping, inputs.inputMapping)
if (normalized && typeof normalized === 'object' && !Array.isArray(normalized)) {
- childWorkflowInput = normalized as Record
+ // Perform lazy cleanup: remove orphaned fields from inputMapping
+ // that no longer exist in the child workflow's inputFormat
+ const cleanedMapping = await lazyCleanupInputMapping(
+ ctx.workflowId || 'unknown',
+ block.id,
+ normalized,
+ childWorkflow.rawBlocks || {}
+ )
+ childWorkflowInput = cleanedMapping as Record
} else {
childWorkflowInput = {}
}
@@ -209,6 +218,7 @@ export class WorkflowBlockHandler implements BlockHandler {
name: workflowData.name,
serializedState: serializedWorkflow,
variables: workflowVariables,
+ rawBlocks: workflowState.blocks,
}
}
@@ -281,6 +291,7 @@ export class WorkflowBlockHandler implements BlockHandler {
name: wfData?.name || DEFAULTS.WORKFLOW_NAME,
serializedState: serializedWorkflow,
variables: workflowVariables,
+ rawBlocks: deployedState.blocks,
}
}
diff --git a/apps/sim/executor/utils/lazy-cleanup.ts b/apps/sim/executor/utils/lazy-cleanup.ts
new file mode 100644
index 00000000000..b55e02d3c38
--- /dev/null
+++ b/apps/sim/executor/utils/lazy-cleanup.ts
@@ -0,0 +1,163 @@
+import { db } from '@sim/db'
+import { workflowBlocks } from '@sim/db/schema'
+import { and, eq } from 'drizzle-orm'
+import { createLogger } from '@/lib/logs/console/logger'
+
+const logger = createLogger('LazyCleanup')
+
+/**
+ * Extract valid field names from a child workflow's start block inputFormat
+ *
+ * @param childWorkflowBlocks - The blocks from the child workflow state
+ * @returns Set of valid field names defined in the child's inputFormat
+ */
+function extractValidInputFieldNames(childWorkflowBlocks: Record): Set | null {
+ const validFieldNames = new Set()
+
+ const startBlock = Object.values(childWorkflowBlocks).find((block: any) => {
+ const blockType = block?.type
+ return blockType === 'start_trigger' || blockType === 'input_trigger' || blockType === 'starter'
+ })
+
+ if (!startBlock) {
+ logger.debug('No start block found in child workflow')
+ return null
+ }
+
+ const inputFormat =
+ (startBlock as any)?.subBlocks?.inputFormat?.value ??
+ (startBlock as any)?.config?.params?.inputFormat
+
+ if (!Array.isArray(inputFormat)) {
+ logger.debug('No inputFormat array found in child workflow start block')
+ return null
+ }
+
+ // Extract field names
+ for (const field of inputFormat) {
+ if (field?.name && typeof field.name === 'string') {
+ const fieldName = field.name.trim()
+ if (fieldName) {
+ validFieldNames.add(fieldName)
+ }
+ }
+ }
+
+ return validFieldNames
+}
+
+/**
+ * Clean up orphaned inputMapping fields that don't exist in child workflow's inputFormat.
+ * This is a lazy cleanup that only runs at execution time and only persists if changes are needed.
+ *
+ * @param parentWorkflowId - The parent workflow ID
+ * @param parentBlockId - The workflow block ID in the parent
+ * @param currentInputMapping - The current inputMapping value from the parent block
+ * @param childWorkflowBlocks - The blocks from the child workflow
+ * @returns The cleaned inputMapping (only different if cleanup was needed)
+ */
+export async function lazyCleanupInputMapping(
+ parentWorkflowId: string,
+ parentBlockId: string,
+ currentInputMapping: any,
+ childWorkflowBlocks: Record
+): Promise {
+ try {
+ if (
+ !currentInputMapping ||
+ typeof currentInputMapping !== 'object' ||
+ Array.isArray(currentInputMapping)
+ ) {
+ return currentInputMapping
+ }
+
+ const validFieldNames = extractValidInputFieldNames(childWorkflowBlocks)
+
+ if (!validFieldNames || validFieldNames.size === 0) {
+ logger.debug('Child workflow has no inputFormat fields, skipping cleanup')
+ return currentInputMapping
+ }
+
+ const orphanedFields: string[] = []
+ for (const fieldName of Object.keys(currentInputMapping)) {
+ if (!validFieldNames.has(fieldName)) {
+ orphanedFields.push(fieldName)
+ }
+ }
+
+ if (orphanedFields.length === 0) {
+ return currentInputMapping
+ }
+
+ const cleanedMapping: Record = {}
+ for (const [fieldName, fieldValue] of Object.entries(currentInputMapping)) {
+ if (validFieldNames.has(fieldName)) {
+ cleanedMapping[fieldName] = fieldValue
+ }
+ }
+
+ logger.info(
+ `Lazy cleanup: Removing ${orphanedFields.length} orphaned field(s) from inputMapping in workflow ${parentWorkflowId}, block ${parentBlockId}: ${orphanedFields.join(', ')}`
+ )
+
+ persistCleanedMapping(parentWorkflowId, parentBlockId, cleanedMapping).catch((error) => {
+ logger.error('Failed to persist cleaned inputMapping:', error)
+ })
+
+ return cleanedMapping
+ } catch (error) {
+ logger.error('Error in lazy cleanup:', error)
+ return currentInputMapping
+ }
+}
+
+/**
+ * Persist the cleaned inputMapping to the database
+ *
+ * @param workflowId - The workflow ID
+ * @param blockId - The block ID
+ * @param cleanedMapping - The cleaned inputMapping value
+ */
+async function persistCleanedMapping(
+ workflowId: string,
+ blockId: string,
+ cleanedMapping: Record
+): Promise {
+ try {
+ await db.transaction(async (tx) => {
+ const [block] = await tx
+ .select({ subBlocks: workflowBlocks.subBlocks })
+ .from(workflowBlocks)
+ .where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId)))
+ .limit(1)
+
+ if (!block) {
+ logger.warn(`Block ${blockId} not found in workflow ${workflowId}, skipping persistence`)
+ return
+ }
+
+ const subBlocks = (block.subBlocks as Record) || {}
+
+ if (subBlocks.inputMapping) {
+ subBlocks.inputMapping = {
+ ...subBlocks.inputMapping,
+ value: cleanedMapping,
+ }
+
+ // Persist updated subBlocks
+ await tx
+ .update(workflowBlocks)
+ .set({
+ subBlocks: subBlocks,
+ updatedAt: new Date(),
+ })
+ .where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId)))
+
+ logger.info(`Successfully persisted cleaned inputMapping for block ${blockId}`)
+ }
+ })
+ } catch (error) {
+ logger.error('Error persisting cleaned mapping:', error)
+ throw error
+ }
+}
diff --git a/apps/sim/hooks/queries/oauth-credentials.ts b/apps/sim/hooks/queries/oauth-credentials.ts
new file mode 100644
index 00000000000..f6923216530
--- /dev/null
+++ b/apps/sim/hooks/queries/oauth-credentials.ts
@@ -0,0 +1,88 @@
+import { useQuery } from '@tanstack/react-query'
+import type { Credential } from '@/lib/oauth'
+import { fetchJson } from '@/hooks/selectors/helpers'
+
+interface CredentialListResponse {
+ credentials?: Credential[]
+}
+
+interface CredentialDetailResponse {
+ credentials?: Credential[]
+}
+
+export const oauthCredentialKeys = {
+ list: (providerId?: string) => ['oauthCredentials', providerId ?? 'none'] as const,
+ detail: (credentialId?: string, workflowId?: string) =>
+ ['oauthCredentialDetail', credentialId ?? 'none', workflowId ?? 'none'] as const,
+}
+
+export async function fetchOAuthCredentials(providerId: string): Promise {
+ if (!providerId) return []
+ const data = await fetchJson('/api/auth/oauth/credentials', {
+ searchParams: { provider: providerId },
+ })
+ return data.credentials ?? []
+}
+
+export async function fetchOAuthCredentialDetail(
+ credentialId: string,
+ workflowId?: string
+): Promise {
+ if (!credentialId) return []
+ const data = await fetchJson('/api/auth/oauth/credentials', {
+ searchParams: {
+ credentialId,
+ workflowId,
+ },
+ })
+ return data.credentials ?? []
+}
+
+export function useOAuthCredentials(providerId?: string, enabled = true) {
+ return useQuery({
+ queryKey: oauthCredentialKeys.list(providerId),
+ queryFn: () => fetchOAuthCredentials(providerId ?? ''),
+ enabled: Boolean(providerId) && enabled,
+ staleTime: 60 * 1000,
+ })
+}
+
+export function useOAuthCredentialDetail(
+ credentialId?: string,
+ workflowId?: string,
+ enabled = true
+) {
+ return useQuery({
+ queryKey: oauthCredentialKeys.detail(credentialId, workflowId),
+ queryFn: () => fetchOAuthCredentialDetail(credentialId ?? '', workflowId),
+ enabled: Boolean(credentialId) && enabled,
+ staleTime: 60 * 1000,
+ })
+}
+
+export function useCredentialName(credentialId?: string, providerId?: string, workflowId?: string) {
+ const { data: credentials = [], isFetching: credentialsLoading } = useOAuthCredentials(
+ providerId,
+ Boolean(providerId)
+ )
+
+ const selectedCredential = credentials.find((cred) => cred.id === credentialId)
+
+ const shouldFetchDetail = Boolean(credentialId && !selectedCredential && providerId && workflowId)
+
+ const { data: foreignCredentials = [], isFetching: foreignLoading } = useOAuthCredentialDetail(
+ shouldFetchDetail ? credentialId : undefined,
+ workflowId,
+ shouldFetchDetail
+ )
+
+ const hasForeignMeta = foreignCredentials.length > 0
+
+ const displayName = selectedCredential?.name ?? (hasForeignMeta ? 'Saved by collaborator' : null)
+
+ return {
+ displayName,
+ isLoading: credentialsLoading || foreignLoading,
+ hasForeignMeta,
+ }
+}
diff --git a/apps/sim/hooks/queries/templates.ts b/apps/sim/hooks/queries/templates.ts
new file mode 100644
index 00000000000..1b3824a2483
--- /dev/null
+++ b/apps/sim/hooks/queries/templates.ts
@@ -0,0 +1,396 @@
+import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import { createLogger } from '@/lib/logs/console/logger'
+
+const logger = createLogger('TemplateQueries')
+
+export const templateKeys = {
+ all: ['templates'] as const,
+ lists: () => [...templateKeys.all, 'list'] as const,
+ list: (filters?: TemplateListFilters) => [...templateKeys.lists(), filters ?? {}] as const,
+ details: () => [...templateKeys.all, 'detail'] as const,
+ detail: (templateId?: string) => [...templateKeys.details(), templateId ?? ''] as const,
+ byWorkflow: (workflowId?: string) =>
+ [...templateKeys.all, 'byWorkflow', workflowId ?? ''] as const,
+}
+
+export interface TemplateListFilters {
+ search?: string
+ status?: 'pending' | 'approved' | 'rejected'
+ workflowId?: string
+ limit?: number
+ offset?: number
+ includeAllStatuses?: boolean
+}
+
+export interface TemplateCreator {
+ id: string
+ name: string
+ referenceType: 'user' | 'organization'
+ referenceId: string
+ email?: string
+ website?: string
+ profileImageUrl?: string | null
+ details?: {
+ about?: string
+ xUrl?: string
+ linkedinUrl?: string
+ websiteUrl?: string
+ contactEmail?: string
+ } | null
+ createdAt: string
+ updatedAt: string
+}
+
+export interface Template {
+ id: string
+ workflowId: string
+ name: string
+ details?: {
+ tagline?: string
+ about?: string
+ }
+ creatorId?: string
+ creator?: TemplateCreator
+ views: number
+ stars: number
+ status: 'pending' | 'approved' | 'rejected'
+ tags: string[]
+ requiredCredentials: Record
+ state: any
+ createdAt: string
+ updatedAt: string
+ isStarred?: boolean
+ isSuperUser?: boolean
+}
+
+export interface TemplatesResponse {
+ data: Template[]
+ pagination: {
+ total: number
+ limit: number
+ offset: number
+ page: number
+ totalPages: number
+ }
+}
+
+export interface TemplateDetailResponse {
+ data: Template
+}
+
+export interface CreateTemplateInput {
+ workflowId: string
+ name: string
+ details?: {
+ tagline?: string
+ about?: string
+ }
+ creatorId?: string
+ tags?: string[]
+}
+
+export interface UpdateTemplateInput {
+ name?: string
+ details?: {
+ tagline?: string
+ about?: string
+ }
+ creatorId?: string
+ tags?: string[]
+ updateState?: boolean
+}
+
+async function fetchTemplates(filters?: TemplateListFilters): Promise {
+ const params = new URLSearchParams()
+
+ if (filters?.search) params.set('search', filters.search)
+ if (filters?.status) params.set('status', filters.status)
+ if (filters?.workflowId) params.set('workflowId', filters.workflowId)
+ if (filters?.includeAllStatuses) params.set('includeAllStatuses', 'true')
+ params.set('limit', (filters?.limit ?? 50).toString())
+ params.set('offset', (filters?.offset ?? 0).toString())
+
+ const response = await fetch(`/api/templates?${params.toString()}`)
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}))
+ throw new Error(errorData.error || 'Failed to fetch templates')
+ }
+
+ return response.json()
+}
+
+async function fetchTemplate(templateId: string): Promise {
+ const response = await fetch(`/api/templates/${templateId}`)
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}))
+ throw new Error(errorData.error || 'Failed to fetch template')
+ }
+
+ return response.json()
+}
+
+async function fetchTemplateByWorkflow(workflowId: string): Promise {
+ const response = await fetch(`/api/templates?workflowId=${workflowId}&limit=1`)
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}))
+ throw new Error(errorData.error || 'Failed to fetch template')
+ }
+
+ const result: TemplatesResponse = await response.json()
+ return result.data?.[0] || null
+}
+
+export function useTemplates(
+ filters?: TemplateListFilters,
+ options?: {
+ enabled?: boolean
+ }
+) {
+ return useQuery({
+ queryKey: templateKeys.list(filters),
+ queryFn: () => fetchTemplates(filters),
+ enabled: options?.enabled ?? true,
+ staleTime: 5 * 60 * 1000, // 5 minutes - templates don't change frequently
+ placeholderData: keepPreviousData,
+ })
+}
+
+export function useTemplate(
+ templateId?: string,
+ options?: {
+ enabled?: boolean
+ }
+) {
+ return useQuery({
+ queryKey: templateKeys.detail(templateId),
+ queryFn: () => fetchTemplate(templateId as string),
+ enabled: (options?.enabled ?? true) && Boolean(templateId),
+ staleTime: 10 * 60 * 1000, // 10 minutes - individual templates are fairly static
+ select: (data) => data.data,
+ })
+}
+
+export function useTemplateByWorkflow(
+ workflowId?: string,
+ options?: {
+ enabled?: boolean
+ }
+) {
+ return useQuery({
+ queryKey: templateKeys.byWorkflow(workflowId),
+ queryFn: () => fetchTemplateByWorkflow(workflowId as string),
+ enabled: (options?.enabled ?? true) && Boolean(workflowId),
+ staleTime: 5 * 60 * 1000, // 5 minutes
+ })
+}
+
+export function useCreateTemplate() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async (data: CreateTemplateInput) => {
+ const response = await fetch('/api/templates', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(data),
+ })
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}))
+ throw new Error(errorData.error || 'Failed to create template')
+ }
+
+ return response.json()
+ },
+ onSuccess: (_, variables) => {
+ queryClient.invalidateQueries({ queryKey: templateKeys.lists() })
+ queryClient.invalidateQueries({ queryKey: templateKeys.byWorkflow(variables.workflowId) })
+ logger.info('Template created successfully')
+ },
+ onError: (error) => {
+ logger.error('Failed to create template', error)
+ },
+ })
+}
+
+export function useUpdateTemplate() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async ({ id, data }: { id: string; data: UpdateTemplateInput }) => {
+ const response = await fetch(`/api/templates/${id}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(data),
+ })
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}))
+ throw new Error(errorData.error || 'Failed to update template')
+ }
+
+ return response.json()
+ },
+ onMutate: async ({ id, data }) => {
+ await queryClient.cancelQueries({ queryKey: templateKeys.detail(id) })
+
+ const previousTemplate = queryClient.getQueryData(
+ templateKeys.detail(id)
+ )
+
+ if (previousTemplate) {
+ queryClient.setQueryData(templateKeys.detail(id), {
+ ...previousTemplate,
+ data: {
+ ...previousTemplate.data,
+ ...data,
+ updatedAt: new Date().toISOString(),
+ },
+ })
+ }
+
+ return { previousTemplate }
+ },
+ onError: (error, { id }, context) => {
+ if (context?.previousTemplate) {
+ queryClient.setQueryData(templateKeys.detail(id), context.previousTemplate)
+ }
+ logger.error('Failed to update template', error)
+ },
+ onSuccess: (result, { id }) => {
+ queryClient.setQueryData(templateKeys.detail(id), result)
+
+ queryClient.invalidateQueries({ queryKey: templateKeys.lists() })
+
+ if (result.data?.workflowId) {
+ queryClient.invalidateQueries({
+ queryKey: templateKeys.byWorkflow(result.data.workflowId),
+ })
+ }
+
+ logger.info('Template updated successfully')
+ },
+ })
+}
+
+export function useDeleteTemplate() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async (templateId: string) => {
+ const response = await fetch(`/api/templates/${templateId}`, {
+ method: 'DELETE',
+ })
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}))
+ throw new Error(errorData.error || 'Failed to delete template')
+ }
+
+ return response.json()
+ },
+ onSuccess: (_, templateId) => {
+ queryClient.removeQueries({ queryKey: templateKeys.detail(templateId) })
+
+ queryClient.invalidateQueries({ queryKey: templateKeys.lists() })
+
+ queryClient.invalidateQueries({
+ queryKey: [...templateKeys.all, 'byWorkflow'],
+ exact: false,
+ })
+
+ logger.info('Template deleted successfully')
+ },
+ onError: (error) => {
+ logger.error('Failed to delete template', error)
+ },
+ })
+}
+
+export function useStarTemplate() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async ({
+ templateId,
+ action,
+ }: {
+ templateId: string
+ action: 'add' | 'remove'
+ }) => {
+ const method = action === 'add' ? 'POST' : 'DELETE'
+ const response = await fetch(`/api/templates/${templateId}/star`, { method })
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}))
+ throw new Error(errorData.error || 'Failed to toggle star')
+ }
+
+ return response.json()
+ },
+ onMutate: async ({ templateId, action }) => {
+ await queryClient.cancelQueries({ queryKey: templateKeys.detail(templateId) })
+
+ const previousTemplate = queryClient.getQueryData(
+ templateKeys.detail(templateId)
+ )
+
+ if (previousTemplate) {
+ const newStarCount =
+ action === 'add'
+ ? previousTemplate.data.stars + 1
+ : Math.max(0, previousTemplate.data.stars - 1)
+
+ queryClient.setQueryData(templateKeys.detail(templateId), {
+ ...previousTemplate,
+ data: {
+ ...previousTemplate.data,
+ stars: newStarCount,
+ isStarred: action === 'add',
+ },
+ })
+ }
+
+ const listQueries = queryClient.getQueriesData({
+ queryKey: templateKeys.lists(),
+ })
+
+ listQueries.forEach(([key, data]) => {
+ if (!data) return
+ queryClient.setQueryData(key, {
+ ...data,
+ data: data.data.map((template) => {
+ if (template.id === templateId) {
+ const newStarCount =
+ action === 'add' ? template.stars + 1 : Math.max(0, template.stars - 1)
+ return {
+ ...template,
+ stars: newStarCount,
+ isStarred: action === 'add',
+ }
+ }
+ return template
+ }),
+ })
+ })
+
+ return { previousTemplate }
+ },
+ onError: (error, { templateId }, context) => {
+ if (context?.previousTemplate) {
+ queryClient.setQueryData(templateKeys.detail(templateId), context.previousTemplate)
+ }
+
+ queryClient.invalidateQueries({ queryKey: templateKeys.lists() })
+
+ logger.error('Failed to toggle star', error)
+ },
+ onSettled: (_, __, { templateId }) => {
+ queryClient.invalidateQueries({ queryKey: templateKeys.detail(templateId) })
+ queryClient.invalidateQueries({ queryKey: templateKeys.lists() })
+ },
+ })
+}
diff --git a/apps/sim/hooks/queries/workflows.ts b/apps/sim/hooks/queries/workflows.ts
index 29178132018..34156587f17 100644
--- a/apps/sim/hooks/queries/workflows.ts
+++ b/apps/sim/hooks/queries/workflows.ts
@@ -105,23 +105,18 @@ export function useCreateWorkflow() {
const { workflowState } = buildDefaultWorkflowArtifacts()
- fetch(`/api/workflows/${workflowId}/state`, {
+ const stateResponse = await fetch(`/api/workflows/${workflowId}/state`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(workflowState),
})
- .then((response) => {
- if (!response.ok) {
- response.text().then((text) => {
- logger.error('Failed to persist default Start block:', text)
- })
- } else {
- logger.info('Successfully persisted default Start block')
- }
- })
- .catch((error) => {
- logger.error('Error persisting default Start block:', error)
- })
+
+ if (!stateResponse.ok) {
+ const text = await stateResponse.text()
+ logger.error('Failed to persist default Start block:', text)
+ } else {
+ logger.info('Successfully persisted default Start block')
+ }
return {
id: workflowId,
diff --git a/apps/sim/hooks/selectors/helpers.ts b/apps/sim/hooks/selectors/helpers.ts
new file mode 100644
index 00000000000..438e9415d7f
--- /dev/null
+++ b/apps/sim/hooks/selectors/helpers.ts
@@ -0,0 +1,61 @@
+import { createLogger } from '@/lib/logs/console/logger'
+
+const logger = createLogger('SelectorHelpers')
+
+interface FetchJsonOptions extends RequestInit {
+ searchParams?: Record
+}
+
+export async function fetchJson(url: string, options: FetchJsonOptions = {}): Promise {
+ const { searchParams, headers, ...rest } = options
+ let finalUrl = url
+ if (searchParams) {
+ const params = new URLSearchParams()
+ Object.entries(searchParams).forEach(([key, value]) => {
+ if (value === undefined || value === null || value === '') return
+ params.set(key, String(value))
+ })
+ const qs = params.toString()
+ if (qs) {
+ finalUrl = `${url}${url.includes('?') ? '&' : '?'}${qs}`
+ }
+ }
+
+ const response = await fetch(finalUrl, {
+ headers: {
+ 'Content-Type': 'application/json',
+ ...headers,
+ },
+ ...rest,
+ })
+
+ if (!response.ok) {
+ let message = `Failed request ${response.status}`
+ try {
+ const err = await response.json()
+ message = err.error || err.message || message
+ } catch (error) {
+ logger.warn('Failed to parse error response', { error })
+ }
+ throw new Error(message)
+ }
+
+ return response.json()
+}
+
+interface TokenResponse {
+ accessToken?: string
+}
+
+export async function fetchOAuthToken(
+ credentialId: string,
+ workflowId?: string
+): Promise {
+ if (!credentialId) return null
+ const body = JSON.stringify({ credentialId, workflowId })
+ const token = await fetchJson('/api/auth/oauth/token', {
+ method: 'POST',
+ body,
+ })
+ return token.accessToken ?? null
+}
diff --git a/apps/sim/hooks/selectors/registry.ts b/apps/sim/hooks/selectors/registry.ts
new file mode 100644
index 00000000000..3647559e197
--- /dev/null
+++ b/apps/sim/hooks/selectors/registry.ts
@@ -0,0 +1,646 @@
+import { fetchJson, fetchOAuthToken } from './helpers'
+import type {
+ SelectorContext,
+ SelectorDefinition,
+ SelectorKey,
+ SelectorOption,
+ SelectorQueryArgs,
+} from './types'
+
+const SELECTOR_STALE = 60 * 1000
+
+type SlackChannel = { id: string; name: string }
+type FolderResponse = { id: string; name: string }
+type PlannerTask = { id: string; title: string }
+
+const ensureCredential = (context: SelectorContext, key: SelectorKey): string => {
+ if (!context.credentialId) {
+ throw new Error(`Missing credential for selector ${key}`)
+ }
+ return context.credentialId
+}
+
+const ensureDomain = (context: SelectorContext, key: SelectorKey): string => {
+ if (!context.domain) {
+ throw new Error(`Missing domain for selector ${key}`)
+ }
+ return context.domain
+}
+
+const ensureKnowledgeBase = (context: SelectorContext): string => {
+ if (!context.knowledgeBaseId) {
+ throw new Error('Missing knowledge base id')
+ }
+ return context.knowledgeBaseId
+}
+
+const registry: Record = {
+ 'slack.channels': {
+ key: 'slack.channels',
+ staleTime: SELECTOR_STALE,
+ getQueryKey: ({ context }: SelectorQueryArgs) => [
+ 'selectors',
+ 'slack.channels',
+ context.credentialId ?? 'none',
+ ],
+ enabled: ({ context }) => Boolean(context.credentialId),
+ fetchList: async ({ context }: SelectorQueryArgs) => {
+ const body = JSON.stringify({
+ credential: context.credentialId,
+ workflowId: context.workflowId,
+ })
+ const data = await fetchJson<{ channels: SlackChannel[] }>('/api/tools/slack/channels', {
+ method: 'POST',
+ body,
+ })
+ return (data.channels || []).map((channel) => ({
+ id: channel.id,
+ label: `#${channel.name}`,
+ }))
+ },
+ },
+ 'gmail.labels': {
+ key: 'gmail.labels',
+ staleTime: SELECTOR_STALE,
+ getQueryKey: ({ context }: SelectorQueryArgs) => [
+ 'selectors',
+ 'gmail.labels',
+ context.credentialId ?? 'none',
+ ],
+ enabled: ({ context }) => Boolean(context.credentialId),
+ fetchList: async ({ context }: SelectorQueryArgs) => {
+ const data = await fetchJson<{ labels: FolderResponse[] }>('/api/tools/gmail/labels', {
+ searchParams: { credentialId: context.credentialId },
+ })
+ return (data.labels || []).map((label) => ({
+ id: label.id,
+ label: label.name,
+ }))
+ },
+ },
+ 'outlook.folders': {
+ key: 'outlook.folders',
+ staleTime: SELECTOR_STALE,
+ getQueryKey: ({ context }: SelectorQueryArgs) => [
+ 'selectors',
+ 'outlook.folders',
+ context.credentialId ?? 'none',
+ ],
+ enabled: ({ context }) => Boolean(context.credentialId),
+ fetchList: async ({ context }: SelectorQueryArgs) => {
+ const data = await fetchJson<{ folders: FolderResponse[] }>('/api/tools/outlook/folders', {
+ searchParams: { credentialId: context.credentialId },
+ })
+ return (data.folders || []).map((folder) => ({
+ id: folder.id,
+ label: folder.name,
+ }))
+ },
+ },
+ 'google.calendar': {
+ key: 'google.calendar',
+ staleTime: SELECTOR_STALE,
+ getQueryKey: ({ context }: SelectorQueryArgs) => [
+ 'selectors',
+ 'google.calendar',
+ context.credentialId ?? 'none',
+ ],
+ enabled: ({ context }) => Boolean(context.credentialId),
+ fetchList: async ({ context }: SelectorQueryArgs) => {
+ const data = await fetchJson<{ calendars: { id: string; summary: string }[] }>(
+ '/api/tools/google_calendar/calendars',
+ { searchParams: { credentialId: context.credentialId } }
+ )
+ return (data.calendars || []).map((calendar) => ({
+ id: calendar.id,
+ label: calendar.summary,
+ }))
+ },
+ },
+ 'microsoft.teams': {
+ key: 'microsoft.teams',
+ staleTime: SELECTOR_STALE,
+ getQueryKey: ({ context }: SelectorQueryArgs) => [
+ 'selectors',
+ 'microsoft.teams',
+ context.credentialId ?? 'none',
+ ],
+ enabled: ({ context }) => Boolean(context.credentialId),
+ fetchList: async ({ context }: SelectorQueryArgs) => {
+ const body = JSON.stringify({ credential: context.credentialId })
+ const data = await fetchJson<{ teams: { id: string; displayName: string }[] }>(
+ '/api/tools/microsoft-teams/teams',
+ { method: 'POST', body }
+ )
+ return (data.teams || []).map((team) => ({
+ id: team.id,
+ label: team.displayName,
+ }))
+ },
+ },
+ 'wealthbox.contacts': {
+ key: 'wealthbox.contacts',
+ staleTime: SELECTOR_STALE,
+ getQueryKey: ({ context }: SelectorQueryArgs) => [
+ 'selectors',
+ 'wealthbox.contacts',
+ context.credentialId ?? 'none',
+ ],
+ enabled: ({ context }) => Boolean(context.credentialId),
+ fetchList: async ({ context }: SelectorQueryArgs) => {
+ const data = await fetchJson<{ items: { id: string; name: string }[] }>(
+ '/api/tools/wealthbox/items',
+ {
+ searchParams: { credentialId: context.credentialId, type: 'contact' },
+ }
+ )
+ return (data.items || []).map((item) => ({
+ id: item.id,
+ label: item.name,
+ }))
+ },
+ },
+ 'sharepoint.sites': {
+ key: 'sharepoint.sites',
+ staleTime: SELECTOR_STALE,
+ getQueryKey: ({ context }: SelectorQueryArgs) => [
+ 'selectors',
+ 'sharepoint.sites',
+ context.credentialId ?? 'none',
+ ],
+ enabled: ({ context }) => Boolean(context.credentialId),
+ fetchList: async ({ context }: SelectorQueryArgs) => {
+ const data = await fetchJson<{ files: { id: string; name: string }[] }>(
+ '/api/tools/sharepoint/sites',
+ {
+ searchParams: { credentialId: context.credentialId },
+ }
+ )
+ return (data.files || []).map((file) => ({
+ id: file.id,
+ label: file.name,
+ }))
+ },
+ },
+ 'microsoft.planner': {
+ key: 'microsoft.planner',
+ staleTime: SELECTOR_STALE,
+ getQueryKey: ({ context }: SelectorQueryArgs) => [
+ 'selectors',
+ 'microsoft.planner',
+ context.credentialId ?? 'none',
+ context.planId ?? 'none',
+ ],
+ enabled: ({ context }) => Boolean(context.credentialId && context.planId),
+ fetchList: async ({ context }: SelectorQueryArgs) => {
+ const data = await fetchJson<{ tasks: PlannerTask[] }>('/api/tools/microsoft_planner/tasks', {
+ searchParams: {
+ credentialId: context.credentialId,
+ planId: context.planId,
+ },
+ })
+ return (data.tasks || []).map((task) => ({
+ id: task.id,
+ label: task.title,
+ }))
+ },
+ },
+ 'jira.projects': {
+ key: 'jira.projects',
+ staleTime: SELECTOR_STALE,
+ getQueryKey: ({ context, search }: SelectorQueryArgs) => [
+ 'selectors',
+ 'jira.projects',
+ context.credentialId ?? 'none',
+ context.domain ?? 'none',
+ search ?? '',
+ ],
+ enabled: ({ context }) => Boolean(context.credentialId && context.domain),
+ fetchList: async ({ context, search }: SelectorQueryArgs) => {
+ const credentialId = ensureCredential(context, 'jira.projects')
+ const domain = ensureDomain(context, 'jira.projects')
+ const accessToken = await fetchOAuthToken(credentialId, context.workflowId)
+ if (!accessToken) {
+ throw new Error('Missing Jira access token')
+ }
+ const data = await fetchJson<{ projects: { id: string; name: string }[] }>(
+ '/api/tools/jira/projects',
+ {
+ searchParams: {
+ domain,
+ accessToken,
+ query: search ?? '',
+ },
+ }
+ )
+ return (data.projects || []).map((project) => ({
+ id: project.id,
+ label: project.name,
+ }))
+ },
+ fetchById: async ({ context, detailId }: SelectorQueryArgs) => {
+ if (!detailId) return null
+ const credentialId = ensureCredential(context, 'jira.projects')
+ const domain = ensureDomain(context, 'jira.projects')
+ const accessToken = await fetchOAuthToken(credentialId, context.workflowId)
+ if (!accessToken) {
+ throw new Error('Missing Jira access token')
+ }
+ const data = await fetchJson<{ project?: { id: string; name: string } }>(
+ '/api/tools/jira/projects',
+ {
+ method: 'POST',
+ body: JSON.stringify({
+ domain,
+ accessToken,
+ projectId: detailId,
+ }),
+ }
+ )
+ if (!data.project) return null
+ return {
+ id: data.project.id,
+ label: data.project.name,
+ }
+ },
+ },
+ 'jira.issues': {
+ key: 'jira.issues',
+ staleTime: 15 * 1000,
+ getQueryKey: ({ context, search }: SelectorQueryArgs) => [
+ 'selectors',
+ 'jira.issues',
+ context.credentialId ?? 'none',
+ context.domain ?? 'none',
+ context.projectId ?? 'none',
+ search ?? '',
+ ],
+ enabled: ({ context }) => Boolean(context.credentialId && context.domain),
+ fetchList: async ({ context, search }: SelectorQueryArgs) => {
+ const credentialId = ensureCredential(context, 'jira.issues')
+ const domain = ensureDomain(context, 'jira.issues')
+ const accessToken = await fetchOAuthToken(credentialId, context.workflowId)
+ if (!accessToken) {
+ throw new Error('Missing Jira access token')
+ }
+ const data = await fetchJson<{
+ sections?: { issues: { id?: string; key?: string; summary?: string }[] }[]
+ }>('/api/tools/jira/issues', {
+ searchParams: {
+ domain,
+ accessToken,
+ projectId: context.projectId,
+ query: search ?? '',
+ },
+ })
+ const issues =
+ data.sections?.flatMap((section) =>
+ (section.issues || []).map((issue) => ({
+ id: issue.id || issue.key || '',
+ name: issue.summary || issue.key || '',
+ }))
+ ) || []
+ return issues
+ .filter((issue) => issue.id)
+ .map((issue) => ({ id: issue.id, label: issue.name || issue.id }))
+ },
+ fetchById: async ({ context, detailId }: SelectorQueryArgs) => {
+ if (!detailId) return null
+ const credentialId = ensureCredential(context, 'jira.issues')
+ const domain = ensureDomain(context, 'jira.issues')
+ const accessToken = await fetchOAuthToken(credentialId, context.workflowId)
+ if (!accessToken) {
+ throw new Error('Missing Jira access token')
+ }
+ const data = await fetchJson<{ issues?: { id: string; name: string }[] }>(
+ '/api/tools/jira/issues',
+ {
+ method: 'POST',
+ body: JSON.stringify({
+ domain,
+ accessToken,
+ issueKeys: [detailId],
+ }),
+ }
+ )
+ const issue = data.issues?.[0]
+ if (!issue) return null
+ return { id: issue.id, label: issue.name }
+ },
+ },
+ 'linear.teams': {
+ key: 'linear.teams',
+ staleTime: SELECTOR_STALE,
+ getQueryKey: ({ context }: SelectorQueryArgs) => [
+ 'selectors',
+ 'linear.teams',
+ context.credentialId ?? 'none',
+ ],
+ enabled: ({ context }) => Boolean(context.credentialId),
+ fetchList: async ({ context }: SelectorQueryArgs) => {
+ const credentialId = ensureCredential(context, 'linear.teams')
+ const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId })
+ const data = await fetchJson<{ teams: { id: string; name: string }[] }>(
+ '/api/tools/linear/teams',
+ {
+ method: 'POST',
+ body,
+ }
+ )
+ return (data.teams || []).map((team) => ({
+ id: team.id,
+ label: team.name,
+ }))
+ },
+ },
+ 'linear.projects': {
+ key: 'linear.projects',
+ staleTime: SELECTOR_STALE,
+ getQueryKey: ({ context }: SelectorQueryArgs) => [
+ 'selectors',
+ 'linear.projects',
+ context.credentialId ?? 'none',
+ context.teamId ?? 'none',
+ ],
+ enabled: ({ context }) => Boolean(context.credentialId && context.teamId),
+ fetchList: async ({ context }: SelectorQueryArgs) => {
+ const credentialId = ensureCredential(context, 'linear.projects')
+ const body = JSON.stringify({
+ credential: credentialId,
+ teamId: context.teamId,
+ workflowId: context.workflowId,
+ })
+ const data = await fetchJson<{ projects: { id: string; name: string }[] }>(
+ '/api/tools/linear/projects',
+ {
+ method: 'POST',
+ body,
+ }
+ )
+ return (data.projects || []).map((project) => ({
+ id: project.id,
+ label: project.name,
+ }))
+ },
+ },
+ 'confluence.pages': {
+ key: 'confluence.pages',
+ staleTime: SELECTOR_STALE,
+ getQueryKey: ({ context, search }: SelectorQueryArgs) => [
+ 'selectors',
+ 'confluence.pages',
+ context.credentialId ?? 'none',
+ context.domain ?? 'none',
+ search ?? '',
+ ],
+ enabled: ({ context }) => Boolean(context.credentialId && context.domain),
+ fetchList: async ({ context, search }: SelectorQueryArgs) => {
+ const credentialId = ensureCredential(context, 'confluence.pages')
+ const domain = ensureDomain(context, 'confluence.pages')
+ const accessToken = await fetchOAuthToken(credentialId, context.workflowId)
+ if (!accessToken) {
+ throw new Error('Missing Confluence access token')
+ }
+ const data = await fetchJson<{ files: { id: string; name: string }[] }>(
+ '/api/tools/confluence/pages',
+ {
+ method: 'POST',
+ body: JSON.stringify({
+ domain,
+ accessToken,
+ title: search,
+ }),
+ }
+ )
+ return (data.files || []).map((file) => ({
+ id: file.id,
+ label: file.name,
+ }))
+ },
+ fetchById: async ({ context, detailId }: SelectorQueryArgs) => {
+ if (!detailId) return null
+ const credentialId = ensureCredential(context, 'confluence.pages')
+ const domain = ensureDomain(context, 'confluence.pages')
+ const accessToken = await fetchOAuthToken(credentialId, context.workflowId)
+ if (!accessToken) {
+ throw new Error('Missing Confluence access token')
+ }
+ const data = await fetchJson<{ id: string; title: string }>('/api/tools/confluence/page', {
+ method: 'POST',
+ body: JSON.stringify({
+ domain,
+ accessToken,
+ pageId: detailId,
+ }),
+ })
+ return { id: data.id, label: data.title }
+ },
+ },
+ 'onedrive.files': {
+ key: 'onedrive.files',
+ staleTime: SELECTOR_STALE,
+ getQueryKey: ({ context }: SelectorQueryArgs) => [
+ 'selectors',
+ 'onedrive.files',
+ context.credentialId ?? 'none',
+ ],
+ enabled: ({ context }) => Boolean(context.credentialId),
+ fetchList: async ({ context }: SelectorQueryArgs) => {
+ const credentialId = ensureCredential(context, 'onedrive.files')
+ const data = await fetchJson<{ files: { id: string; name: string }[] }>(
+ '/api/tools/onedrive/files',
+ {
+ searchParams: { credentialId },
+ }
+ )
+ return (data.files || []).map((file) => ({
+ id: file.id,
+ label: file.name,
+ }))
+ },
+ },
+ 'onedrive.folders': {
+ key: 'onedrive.folders',
+ staleTime: SELECTOR_STALE,
+ getQueryKey: ({ context }: SelectorQueryArgs) => [
+ 'selectors',
+ 'onedrive.folders',
+ context.credentialId ?? 'none',
+ ],
+ enabled: ({ context }) => Boolean(context.credentialId),
+ fetchList: async ({ context }: SelectorQueryArgs) => {
+ const credentialId = ensureCredential(context, 'onedrive.folders')
+ const data = await fetchJson<{ files: { id: string; name: string }[] }>(
+ '/api/tools/onedrive/folders',
+ {
+ searchParams: { credentialId },
+ }
+ )
+ return (data.files || []).map((file) => ({
+ id: file.id,
+ label: file.name,
+ }))
+ },
+ },
+ 'google.drive': {
+ key: 'google.drive',
+ staleTime: 15 * 1000,
+ getQueryKey: ({ context, search }: SelectorQueryArgs) => [
+ 'selectors',
+ 'google.drive',
+ context.credentialId ?? 'none',
+ context.mimeType ?? 'any',
+ context.fileId ?? 'root',
+ search ?? '',
+ ],
+ enabled: ({ context }) => Boolean(context.credentialId),
+ fetchList: async ({ context, search }: SelectorQueryArgs) => {
+ const credentialId = ensureCredential(context, 'google.drive')
+ const data = await fetchJson<{ files: { id: string; name: string }[] }>(
+ '/api/tools/drive/files',
+ {
+ searchParams: {
+ credentialId,
+ mimeType: context.mimeType,
+ parentId: context.fileId,
+ query: search,
+ workflowId: context.workflowId,
+ },
+ }
+ )
+ return (data.files || []).map((file) => ({
+ id: file.id,
+ label: file.name,
+ }))
+ },
+ fetchById: async ({ context, detailId }: SelectorQueryArgs) => {
+ if (!detailId) return null
+ const credentialId = ensureCredential(context, 'google.drive')
+ const data = await fetchJson<{ file?: { id: string; name: string } }>(
+ '/api/tools/drive/file',
+ {
+ searchParams: {
+ credentialId,
+ fileId: detailId,
+ workflowId: context.workflowId,
+ },
+ }
+ )
+ const file = data.file
+ if (!file) return null
+ return { id: file.id, label: file.name }
+ },
+ },
+ 'microsoft.excel': {
+ key: 'microsoft.excel',
+ staleTime: SELECTOR_STALE,
+ getQueryKey: ({ context, search }: SelectorQueryArgs) => [
+ 'selectors',
+ 'microsoft.excel',
+ context.credentialId ?? 'none',
+ search ?? '',
+ ],
+ enabled: ({ context }) => Boolean(context.credentialId),
+ fetchList: async ({ context, search }: SelectorQueryArgs) => {
+ const credentialId = ensureCredential(context, 'microsoft.excel')
+ const data = await fetchJson<{ files: { id: string; name: string }[] }>(
+ '/api/auth/oauth/microsoft/files',
+ {
+ searchParams: {
+ credentialId,
+ query: search,
+ workflowId: context.workflowId,
+ },
+ }
+ )
+ return (data.files || []).map((file) => ({
+ id: file.id,
+ label: file.name,
+ }))
+ },
+ },
+ 'microsoft.word': {
+ key: 'microsoft.word',
+ staleTime: SELECTOR_STALE,
+ getQueryKey: ({ context, search }: SelectorQueryArgs) => [
+ 'selectors',
+ 'microsoft.word',
+ context.credentialId ?? 'none',
+ search ?? '',
+ ],
+ enabled: ({ context }) => Boolean(context.credentialId),
+ fetchList: async ({ context, search }: SelectorQueryArgs) => {
+ const credentialId = ensureCredential(context, 'microsoft.word')
+ const data = await fetchJson<{ files: { id: string; name: string }[] }>(
+ '/api/auth/oauth/microsoft/files',
+ {
+ searchParams: {
+ credentialId,
+ query: search,
+ workflowId: context.workflowId,
+ },
+ }
+ )
+ return (data.files || []).map((file) => ({
+ id: file.id,
+ label: file.name,
+ }))
+ },
+ },
+ 'knowledge.documents': {
+ key: 'knowledge.documents',
+ staleTime: SELECTOR_STALE,
+ getQueryKey: ({ context, search }: SelectorQueryArgs) => [
+ 'selectors',
+ 'knowledge.documents',
+ context.knowledgeBaseId ?? 'none',
+ search ?? '',
+ ],
+ enabled: ({ context }) => Boolean(context.knowledgeBaseId),
+ fetchList: async ({ context, search }: SelectorQueryArgs) => {
+ const knowledgeBaseId = ensureKnowledgeBase(context)
+ const data = await fetchJson<{
+ data?: { documents: { id: string; filename: string }[] }
+ }>(`/api/knowledge/${knowledgeBaseId}/documents`, {
+ searchParams: {
+ limit: 200,
+ search,
+ },
+ })
+ const documents = data.data?.documents || []
+ return documents.map((doc) => ({
+ id: doc.id,
+ label: doc.filename,
+ }))
+ },
+ fetchById: async ({ context, detailId }: SelectorQueryArgs) => {
+ if (!detailId) return null
+ const knowledgeBaseId = ensureKnowledgeBase(context)
+ const data = await fetchJson<{ data?: { document?: { id: string; filename: string } } }>(
+ `/api/knowledge/${knowledgeBaseId}/documents/${detailId}`,
+ {
+ searchParams: { includeDisabled: 'true' },
+ }
+ )
+ const doc = data.data?.document
+ if (!doc) return null
+ return { id: doc.id, label: doc.filename }
+ },
+ },
+}
+
+export function getSelectorDefinition(key: SelectorKey): SelectorDefinition {
+ const definition = registry[key]
+ if (!definition) {
+ throw new Error(`Missing selector definition for ${key}`)
+ }
+ return definition
+}
+
+export function mergeOption(options: SelectorOption[], option?: SelectorOption | null) {
+ if (!option) return options
+ if (options.some((item) => item.id === option.id)) {
+ return options
+ }
+ return [option, ...options]
+}
diff --git a/apps/sim/hooks/selectors/resolution.ts b/apps/sim/hooks/selectors/resolution.ts
new file mode 100644
index 00000000000..7ec3fe59738
--- /dev/null
+++ b/apps/sim/hooks/selectors/resolution.ts
@@ -0,0 +1,172 @@
+import type { SubBlockConfig } from '@/blocks/types'
+import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types'
+
+export interface SelectorResolution {
+ key: SelectorKey | null
+ context: SelectorContext
+ allowSearch: boolean
+}
+
+export interface SelectorResolutionArgs {
+ workflowId?: string
+ credentialId?: string
+ domain?: string
+ projectId?: string
+ planId?: string
+ teamId?: string
+ knowledgeBaseId?: string
+}
+
+const defaultContext: SelectorContext = {}
+
+export function resolveSelectorForSubBlock(
+ subBlock: SubBlockConfig,
+ args: SelectorResolutionArgs
+): SelectorResolution | null {
+ switch (subBlock.type) {
+ case 'file-selector':
+ return resolveFileSelector(subBlock, args)
+ case 'folder-selector':
+ return resolveFolderSelector(subBlock, args)
+ case 'channel-selector':
+ return resolveChannelSelector(subBlock, args)
+ case 'project-selector':
+ return resolveProjectSelector(subBlock, args)
+ case 'document-selector':
+ return resolveDocumentSelector(subBlock, args)
+ default:
+ return null
+ }
+}
+
+function buildBaseContext(
+ args: SelectorResolutionArgs,
+ extra?: Partial
+): SelectorContext {
+ return {
+ ...defaultContext,
+ workflowId: args.workflowId,
+ credentialId: args.credentialId,
+ domain: args.domain,
+ projectId: args.projectId,
+ planId: args.planId,
+ teamId: args.teamId,
+ knowledgeBaseId: args.knowledgeBaseId,
+ ...extra,
+ }
+}
+
+function resolveFileSelector(
+ subBlock: SubBlockConfig,
+ args: SelectorResolutionArgs
+): SelectorResolution {
+ const context = buildBaseContext(args, {
+ mimeType: subBlock.mimeType,
+ })
+
+ const provider = subBlock.provider || subBlock.serviceId || ''
+
+ switch (provider) {
+ case 'google-calendar':
+ return { key: 'google.calendar', context, allowSearch: false }
+ case 'confluence':
+ return { key: 'confluence.pages', context, allowSearch: true }
+ case 'jira':
+ return { key: 'jira.issues', context, allowSearch: true }
+ case 'microsoft-teams':
+ return { key: 'microsoft.teams', context, allowSearch: true }
+ case 'wealthbox':
+ return { key: 'wealthbox.contacts', context, allowSearch: true }
+ case 'microsoft-planner':
+ return { key: 'microsoft.planner', context, allowSearch: true }
+ case 'microsoft-excel':
+ return { key: 'microsoft.excel', context, allowSearch: true }
+ case 'microsoft-word':
+ return { key: 'microsoft.word', context, allowSearch: true }
+ case 'google-drive':
+ return { key: 'google.drive', context, allowSearch: true }
+ case 'google-sheets':
+ return { key: 'google.drive', context, allowSearch: true }
+ case 'google-docs':
+ return { key: 'google.drive', context, allowSearch: true }
+ default:
+ break
+ }
+
+ if (subBlock.serviceId === 'onedrive') {
+ const key: SelectorKey = subBlock.mimeType === 'file' ? 'onedrive.files' : 'onedrive.folders'
+ return { key, context, allowSearch: true }
+ }
+
+ if (subBlock.serviceId === 'sharepoint') {
+ return { key: 'sharepoint.sites', context, allowSearch: true }
+ }
+
+ if (subBlock.serviceId === 'google-sheets') {
+ return { key: 'google.drive', context, allowSearch: true }
+ }
+
+ return { key: null, context, allowSearch: true }
+}
+
+function resolveFolderSelector(
+ subBlock: SubBlockConfig,
+ args: SelectorResolutionArgs
+): SelectorResolution {
+ const provider = (subBlock.provider || subBlock.serviceId || 'gmail').toLowerCase()
+ const key: SelectorKey = provider === 'outlook' ? 'outlook.folders' : 'gmail.labels'
+ return {
+ key,
+ context: buildBaseContext(args),
+ allowSearch: true,
+ }
+}
+
+function resolveChannelSelector(
+ subBlock: SubBlockConfig,
+ args: SelectorResolutionArgs
+): SelectorResolution {
+ const provider = subBlock.provider || 'slack'
+ if (provider !== 'slack') {
+ return { key: null, context: buildBaseContext(args), allowSearch: true }
+ }
+ return {
+ key: 'slack.channels',
+ context: buildBaseContext(args),
+ allowSearch: true,
+ }
+}
+
+function resolveProjectSelector(
+ subBlock: SubBlockConfig,
+ args: SelectorResolutionArgs
+): SelectorResolution {
+ const provider = subBlock.provider || 'jira'
+ const context = buildBaseContext(args)
+
+ if (provider === 'linear') {
+ const key: SelectorKey = subBlock.id === 'teamId' ? 'linear.teams' : 'linear.projects'
+ return {
+ key,
+ context,
+ allowSearch: true,
+ }
+ }
+
+ return {
+ key: 'jira.projects',
+ context,
+ allowSearch: true,
+ }
+}
+
+function resolveDocumentSelector(
+ _subBlock: SubBlockConfig,
+ args: SelectorResolutionArgs
+): SelectorResolution {
+ return {
+ key: 'knowledge.documents',
+ context: buildBaseContext(args),
+ allowSearch: true,
+ }
+}
diff --git a/apps/sim/hooks/selectors/types.ts b/apps/sim/hooks/selectors/types.ts
new file mode 100644
index 00000000000..0eb582c5657
--- /dev/null
+++ b/apps/sim/hooks/selectors/types.ts
@@ -0,0 +1,61 @@
+import type React from 'react'
+import type { QueryKey } from '@tanstack/react-query'
+
+export type SelectorKey =
+ | 'slack.channels'
+ | 'gmail.labels'
+ | 'outlook.folders'
+ | 'google.calendar'
+ | 'jira.issues'
+ | 'jira.projects'
+ | 'linear.projects'
+ | 'linear.teams'
+ | 'confluence.pages'
+ | 'microsoft.teams'
+ | 'wealthbox.contacts'
+ | 'onedrive.files'
+ | 'onedrive.folders'
+ | 'sharepoint.sites'
+ | 'microsoft.excel'
+ | 'microsoft.word'
+ | 'microsoft.planner'
+ | 'google.drive'
+ | 'knowledge.documents'
+
+export interface SelectorOption {
+ id: string
+ label: string
+ icon?: React.ComponentType<{ className?: string }>
+ meta?: Record
+}
+
+export interface SelectorContext {
+ workspaceId?: string
+ workflowId?: string
+ credentialId?: string
+ provider?: string
+ serviceId?: string
+ domain?: string
+ teamId?: string
+ projectId?: string
+ knowledgeBaseId?: string
+ planId?: string
+ mimeType?: string
+ fileId?: string
+}
+
+export interface SelectorQueryArgs {
+ key: SelectorKey
+ context: SelectorContext
+ search?: string
+ detailId?: string
+}
+
+export interface SelectorDefinition {
+ key: SelectorKey
+ getQueryKey: (args: SelectorQueryArgs) => QueryKey
+ fetchList: (args: SelectorQueryArgs) => Promise
+ fetchById?: (args: SelectorQueryArgs) => Promise
+ enabled?: (args: SelectorQueryArgs) => boolean
+ staleTime?: number
+}
diff --git a/apps/sim/hooks/selectors/use-selector-query.ts b/apps/sim/hooks/selectors/use-selector-query.ts
new file mode 100644
index 00000000000..4ef0b262195
--- /dev/null
+++ b/apps/sim/hooks/selectors/use-selector-query.ts
@@ -0,0 +1,61 @@
+import { useMemo } from 'react'
+import { useQuery } from '@tanstack/react-query'
+import { getSelectorDefinition, mergeOption } from './registry'
+import type { SelectorKey, SelectorOption, SelectorQueryArgs } from './types'
+
+interface SelectorHookArgs extends Omit {
+ search?: string
+ detailId?: string
+ enabled?: boolean
+}
+
+export function useSelectorOptions(key: SelectorKey, args: SelectorHookArgs) {
+ const definition = getSelectorDefinition(key)
+ const queryArgs: SelectorQueryArgs = {
+ key,
+ context: args.context,
+ search: args.search,
+ }
+ const isEnabled = args.enabled ?? (definition.enabled ? definition.enabled(queryArgs) : true)
+ return useQuery({
+ queryKey: definition.getQueryKey(queryArgs),
+ queryFn: () => definition.fetchList(queryArgs),
+ enabled: isEnabled,
+ staleTime: definition.staleTime ?? 30_000,
+ })
+}
+
+export function useSelectorOptionDetail(
+ key: SelectorKey,
+ args: SelectorHookArgs & { detailId?: string }
+) {
+ const definition = getSelectorDefinition(key)
+ const queryArgs: SelectorQueryArgs = {
+ key,
+ context: args.context,
+ detailId: args.detailId,
+ }
+ const baseEnabled =
+ Boolean(args.detailId) && definition.fetchById !== undefined
+ ? definition.enabled
+ ? definition.enabled(queryArgs)
+ : true
+ : false
+ const enabled = args.enabled ?? baseEnabled
+
+ const query = useQuery({
+ queryKey: [...definition.getQueryKey(queryArgs), 'detail', args.detailId ?? 'none'],
+ queryFn: () => definition.fetchById!(queryArgs),
+ enabled,
+ staleTime: definition.staleTime ?? 300_000,
+ })
+
+ return query
+}
+
+export function useSelectorOptionMap(options: SelectorOption[], extra?: SelectorOption | null) {
+ return useMemo(() => {
+ const merged = mergeOption(options, extra)
+ return new Map(merged.map((option) => [option.id, option]))
+ }, [options, extra])
+}
diff --git a/apps/sim/hooks/use-collaborative-workflow.ts b/apps/sim/hooks/use-collaborative-workflow.ts
index b3274b4cf4c..2e038927f3c 100644
--- a/apps/sim/hooks/use-collaborative-workflow.ts
+++ b/apps/sim/hooks/use-collaborative-workflow.ts
@@ -31,7 +31,6 @@ export function useCollaborativeWorkflow() {
const moveHandler = (e: any) => {
const { blockId, before, after } = e.detail || {}
if (!blockId || !before || !after) return
- // Don't record moves during undo/redo operations
if (isUndoRedoInProgress.current) return
undoRedo.recordMove(blockId, before, after)
}
@@ -40,7 +39,6 @@ export function useCollaborativeWorkflow() {
const { blockId, oldParentId, newParentId, oldPosition, newPosition, affectedEdges } =
e.detail || {}
if (!blockId) return
- // Don't record during undo/redo operations
if (isUndoRedoInProgress.current) return
undoRedo.recordUpdateParent(
blockId,
@@ -1097,6 +1095,9 @@ export function useCollaborativeWorkflow() {
// Generate operation ID for queue tracking
const operationId = crypto.randomUUID()
+ // Get fresh activeWorkflowId from store to avoid stale closure
+ const currentActiveWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
+
// Add to queue for retry mechanism
addToQueue({
id: operationId,
@@ -1105,7 +1106,7 @@ export function useCollaborativeWorkflow() {
target: 'subblock',
payload: { blockId, subblockId, value },
},
- workflowId: activeWorkflowId || '',
+ workflowId: currentActiveWorkflowId || '',
userId: session?.user?.id || 'unknown',
})
@@ -1134,15 +1135,7 @@ export function useCollaborativeWorkflow() {
// Best-effort; do not block on clearing
}
},
- [
- subBlockStore,
- currentWorkflowId,
- activeWorkflowId,
- addToQueue,
- session?.user?.id,
- isShowingDiff,
- isInActiveRoom,
- ]
+ [subBlockStore, currentWorkflowId, addToQueue, session?.user?.id, isShowingDiff, isInActiveRoom]
)
// Immediate tag selection (uses queue but processes immediately, no debouncing)
diff --git a/apps/sim/hooks/use-credential-display.ts b/apps/sim/hooks/use-credential-display.ts
deleted file mode 100644
index 2f19fdc01be..00000000000
--- a/apps/sim/hooks/use-credential-display.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-import { useCallback, useEffect, useState } from 'react'
-import { useDisplayNamesStore } from '@/stores/display-names/store'
-
-/**
- * Hook to get display name for a credential ID
- * Automatically fetches if not cached
- */
-export function useCredentialDisplay(credentialId: string | undefined, provider?: string) {
- const [isLoading, setIsLoading] = useState(false)
-
- // Select the actual cached value from the store (not just the getter)
- // This ensures the component re-renders when the cache is populated
- const displayName = useDisplayNamesStore(
- useCallback(
- (state) => {
- if (!credentialId || !provider) return null
- return state.cache.credentials[provider]?.[credentialId] || null
- },
- [credentialId, provider]
- )
- )
-
- // Fetch if not cached
- useEffect(() => {
- if (!credentialId || !provider || displayName || isLoading) return
-
- setIsLoading(true)
- fetch(`/api/auth/oauth/credentials?provider=${encodeURIComponent(provider)}`)
- .then((res) => res.json())
- .then((data) => {
- if (data.credentials) {
- const credentialMap = data.credentials.reduce(
- (acc: Record, cred: { id: string; name: string }) => {
- acc[cred.id] = cred.name
- return acc
- },
- {}
- )
- useDisplayNamesStore.getState().setDisplayNames('credentials', provider, credentialMap)
- }
- })
- .catch(() => {
- // Silently fail
- })
- .finally(() => {
- setIsLoading(false)
- })
- }, [credentialId, provider, displayName, isLoading])
-
- return {
- displayName,
- isLoading,
- }
-}
diff --git a/apps/sim/hooks/use-display-name.ts b/apps/sim/hooks/use-display-name.ts
deleted file mode 100644
index b4f49b3a11d..00000000000
--- a/apps/sim/hooks/use-display-name.ts
+++ /dev/null
@@ -1,590 +0,0 @@
-import { useCallback, useEffect, useState } from 'react'
-import type { SubBlockConfig } from '@/blocks/types'
-import { useDisplayNamesStore } from '@/stores/display-names/store'
-import { useKnowledgeStore } from '@/stores/knowledge/store'
-import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
-
-/**
- * Generic hook to get display name for any selector value
- * Automatically fetches if not cached
- */
-export function useDisplayName(
- subBlock: SubBlockConfig | undefined,
- value: unknown,
- context?: {
- workspaceId?: string
- credentialId?: string
- provider?: string
- knowledgeBaseId?: string
- domain?: string
- teamId?: string
- projectId?: string
- planId?: string
- }
-): string | null {
- const getCachedKnowledgeBase = useKnowledgeStore((state) => state.getCachedKnowledgeBase)
- const getKnowledgeBase = useKnowledgeStore((state) => state.getKnowledgeBase)
- const getDocuments = useKnowledgeStore((state) => state.getDocuments)
- const [isFetching, setIsFetching] = useState(false)
-
- const cachedDisplayName = useDisplayNamesStore(
- useCallback(
- (state) => {
- if (!subBlock || !value || typeof value !== 'string') return null
-
- // Channels
- if (subBlock.type === 'channel-selector' && context?.credentialId) {
- return state.cache.channels[context.credentialId]?.[value] || null
- }
-
- // Workflows
- if (subBlock.id === 'workflowId') {
- return state.cache.workflows.global?.[value] || null
- }
-
- // Files
- if (subBlock.type === 'file-selector' && context?.credentialId) {
- return state.cache.files[context.credentialId]?.[value] || null
- }
-
- // Folders
- if (subBlock.type === 'folder-selector' && context?.credentialId) {
- return state.cache.folders[context.credentialId]?.[value] || null
- }
-
- // Projects
- if (subBlock.type === 'project-selector' && context?.provider && context?.credentialId) {
- const projectContext = `${context.provider}-${context.credentialId}`
- return state.cache.projects[projectContext]?.[value] || null
- }
-
- // Documents
- if (subBlock.type === 'document-selector' && context?.knowledgeBaseId) {
- return state.cache.documents[context.knowledgeBaseId]?.[value] || null
- }
-
- return null
- },
- [subBlock, value, context?.credentialId, context?.provider, context?.knowledgeBaseId]
- )
- )
-
- // Auto-fetch knowledge bases if needed
- useEffect(() => {
- if (
- subBlock?.type === 'knowledge-base-selector' &&
- typeof value === 'string' &&
- value &&
- !isFetching
- ) {
- const kb = getCachedKnowledgeBase(value)
- if (!kb) {
- setIsFetching(true)
- getKnowledgeBase(value)
- .catch(() => {
- // Silently fail
- })
- .finally(() => {
- setIsFetching(false)
- })
- }
- }
- }, [subBlock?.type, value, isFetching, getCachedKnowledgeBase, getKnowledgeBase])
-
- // Auto-fetch documents if needed
- useEffect(() => {
- if (
- subBlock?.type === 'document-selector' &&
- context?.knowledgeBaseId &&
- typeof value === 'string' &&
- value &&
- !cachedDisplayName &&
- !isFetching
- ) {
- setIsFetching(true)
- getDocuments(context.knowledgeBaseId)
- .then((docs) => {
- if (docs.length > 0) {
- const documentMap = docs.reduce>((acc, doc) => {
- acc[doc.id] = doc.filename
- return acc
- }, {})
- useDisplayNamesStore
- .getState()
- .setDisplayNames('documents', context.knowledgeBaseId!, documentMap)
- }
- })
- .catch(() => {
- // Silently fail
- })
- .finally(() => {
- setIsFetching(false)
- })
- }
- }, [subBlock?.type, value, context?.knowledgeBaseId, cachedDisplayName, isFetching, getDocuments])
-
- // Auto-fetch workflows if needed
- useEffect(() => {
- if (subBlock?.id !== 'workflowId' || typeof value !== 'string' || !value) return
- if (cachedDisplayName || isFetching) return
-
- const workflows = useWorkflowRegistry.getState().workflows
- if (!workflows[value]) return
-
- const workflowMap = Object.entries(workflows).reduce>(
- (acc, [id, workflow]) => {
- acc[id] = workflow.name || `Workflow ${id.slice(0, 8)}`
- return acc
- },
- {}
- )
-
- useDisplayNamesStore.getState().setDisplayNames('workflows', 'global', workflowMap)
- }, [subBlock?.id, value, cachedDisplayName, isFetching])
-
- // Auto-fetch channels if needed
- useEffect(() => {
- if (subBlock?.type !== 'channel-selector' || !context?.credentialId || !value) return
- if (cachedDisplayName || isFetching) return
-
- setIsFetching(true)
- fetch('/api/tools/slack/channels', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ credential: context.credentialId }),
- })
- .then((res) => res.json())
- .then((data) => {
- if (data.channels) {
- const channelMap = data.channels.reduce(
- (acc: Record, ch: { id: string; name: string }) => {
- acc[ch.id] = ch.name
- return acc
- },
- {}
- )
- useDisplayNamesStore
- .getState()
- .setDisplayNames('channels', context.credentialId!, channelMap)
- }
- })
- .catch(() => {
- // Silently fail
- })
- .finally(() => {
- setIsFetching(false)
- })
- }, [subBlock?.type, value, context?.credentialId, cachedDisplayName, isFetching])
-
- // Auto-fetch folders if needed (Gmail/Outlook)
- useEffect(() => {
- if (subBlock?.type !== 'folder-selector' || !context?.credentialId || !value) return
- if (cachedDisplayName || isFetching) return
-
- setIsFetching(true)
- const provider = subBlock.provider || 'gmail'
- const apiEndpoint =
- provider === 'outlook'
- ? `/api/tools/outlook/folders?credentialId=${context.credentialId}`
- : `/api/tools/gmail/labels?credentialId=${context.credentialId}`
-
- fetch(apiEndpoint)
- .then((res) => res.json())
- .then((data) => {
- const folderList = provider === 'outlook' ? data.folders : data.labels
- if (folderList) {
- const folderMap = folderList.reduce(
- (acc: Record, folder: { id: string; name: string }) => {
- acc[folder.id] = folder.name
- return acc
- },
- {}
- )
- useDisplayNamesStore
- .getState()
- .setDisplayNames('folders', context.credentialId!, folderMap)
- }
- })
- .catch(() => {
- // Silently fail
- })
- .finally(() => {
- setIsFetching(false)
- })
- }, [
- subBlock?.type,
- subBlock?.provider,
- value,
- context?.credentialId,
- cachedDisplayName,
- isFetching,
- ])
-
- // Auto-fetch projects if needed (Jira, Linear)
- useEffect(() => {
- if (
- subBlock?.type !== 'project-selector' ||
- !context?.credentialId ||
- !context?.provider ||
- !value
- )
- return
- if (cachedDisplayName || isFetching) return
-
- const projectContext = `${context.provider}-${context.credentialId}`
- setIsFetching(true)
-
- if (context.provider === 'jira' && context.domain && context.credentialId) {
- // Fetch access token then get project info
- fetch('/api/auth/oauth/token', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ credentialId: context.credentialId }),
- })
- .then((res) => res.json())
- .then((tokenData) => {
- if (!tokenData.accessToken) throw new Error('No access token')
- return fetch('/api/tools/jira/projects', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- domain: context.domain,
- accessToken: tokenData.accessToken,
- projectId: value,
- }),
- })
- })
- .then((res) => res.json())
- .then((data) => {
- if (data.project) {
- useDisplayNamesStore
- .getState()
- .setDisplayNames('projects', projectContext, { [value as string]: data.project.name })
- }
- })
- .catch(() => {})
- .finally(() => setIsFetching(false))
- } else if (context.provider === 'linear' && context.teamId) {
- fetch('/api/tools/linear/projects', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ credential: context.credentialId, teamId: context.teamId }),
- })
- .then((res) => res.json())
- .then((data) => {
- if (data.projects) {
- const projectMap = data.projects.reduce(
- (acc: Record, proj: { id: string; name: string }) => {
- acc[proj.id] = proj.name
- return acc
- },
- {}
- )
- useDisplayNamesStore.getState().setDisplayNames('projects', projectContext, projectMap)
- }
- })
- .catch(() => {})
- .finally(() => setIsFetching(false))
- } else {
- setIsFetching(false)
- }
- }, [
- subBlock?.type,
- value,
- context?.credentialId,
- context?.provider,
- context?.domain,
- context?.teamId,
- ])
-
- // Auto-fetch files if needed (provider-specific)
- useEffect(() => {
- if (subBlock?.type !== 'file-selector' || !context?.credentialId || !value) return
- if (cachedDisplayName || isFetching) return
-
- setIsFetching(true)
- const provider = subBlock.provider || context.provider
- const serviceId = subBlock.serviceId
-
- // Google Calendar
- if (provider === 'google-calendar') {
- fetch(`/api/tools/google_calendar/calendars?credentialId=${context.credentialId}`)
- .then((res) => res.json())
- .then((data) => {
- if (data.calendars) {
- const calendarMap = data.calendars.reduce(
- (acc: Record, cal: { id: string; summary: string }) => {
- acc[cal.id] = cal.summary
- return acc
- },
- {}
- )
- useDisplayNamesStore
- .getState()
- .setDisplayNames('files', context.credentialId!, calendarMap)
- }
- })
- .catch(() => {})
- .finally(() => setIsFetching(false))
- }
- // Jira issues
- else if (provider === 'jira' && context.domain && context.projectId && context.credentialId) {
- // Fetch access token then get issue info
- fetch('/api/auth/oauth/token', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ credentialId: context.credentialId }),
- })
- .then((res) => res.json())
- .then((tokenData) => {
- if (!tokenData.accessToken) throw new Error('No access token')
- return fetch('/api/tools/jira/issues', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- domain: context.domain,
- accessToken: tokenData.accessToken,
- issueKeys: [value],
- }),
- })
- })
- .then((res) => res.json())
- .then((data) => {
- if (data.issues?.[0]) {
- useDisplayNamesStore.getState().setDisplayNames('files', context.credentialId!, {
- [value as string]: data.issues[0].name,
- })
- }
- })
- .catch(() => {})
- .finally(() => setIsFetching(false))
- }
- // Confluence pages
- else if (provider === 'confluence' && context.domain && context.credentialId) {
- // Fetch access token then get page info
- fetch('/api/auth/oauth/token', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ credentialId: context.credentialId }),
- })
- .then((res) => res.json())
- .then((tokenData) => {
- if (!tokenData.accessToken) throw new Error('No access token')
- return fetch('/api/tools/confluence/page', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- domain: context.domain,
- accessToken: tokenData.accessToken,
- pageId: value,
- }),
- })
- })
- .then((res) => res.json())
- .then((data) => {
- if (data.id && data.title) {
- useDisplayNamesStore.getState().setDisplayNames('files', context.credentialId!, {
- [data.id]: data.title,
- })
- }
- })
- .catch(() => {})
- .finally(() => setIsFetching(false))
- }
- // Microsoft Teams
- else if (provider === 'microsoft-teams' && context.credentialId) {
- fetch('/api/tools/microsoft-teams/teams', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ credential: context.credentialId }),
- })
- .then((res) => res.json())
- .then((data) => {
- if (data.teams) {
- const teamMap = data.teams.reduce(
- (acc: Record, team: { id: string; displayName: string }) => {
- acc[team.id] = team.displayName
- return acc
- },
- {}
- )
- useDisplayNamesStore.getState().setDisplayNames('files', context.credentialId!, teamMap)
- }
- })
- .catch(() => {})
- .finally(() => setIsFetching(false))
- }
- // Wealthbox
- else if (provider === 'wealthbox' && context.credentialId) {
- fetch(`/api/tools/wealthbox/items?credentialId=${context.credentialId}&type=contact`)
- .then((res) => res.json())
- .then((data) => {
- if (data.items) {
- const contactMap = data.items.reduce(
- (acc: Record, item: { id: string; name: string }) => {
- acc[item.id] = item.name
- return acc
- },
- {}
- )
- useDisplayNamesStore
- .getState()
- .setDisplayNames('files', context.credentialId!, contactMap)
- }
- })
- .catch(() => {})
- .finally(() => setIsFetching(false))
- }
- // OneDrive files
- else if (serviceId === 'onedrive' && subBlock.mimeType === 'file') {
- fetch(`/api/tools/onedrive/files?credentialId=${context.credentialId}`)
- .then((res) => res.json())
- .then((data) => {
- if (data.files) {
- const fileMap = data.files.reduce(
- (acc: Record, file: { id: string; name: string }) => {
- acc[file.id] = file.name
- return acc
- },
- {}
- )
- useDisplayNamesStore.getState().setDisplayNames('files', context.credentialId!, fileMap)
- }
- })
- .catch(() => {})
- .finally(() => setIsFetching(false))
- }
- // OneDrive folders
- else if (serviceId === 'onedrive' && subBlock.mimeType !== 'file') {
- fetch(`/api/tools/onedrive/folders?credentialId=${context.credentialId}`)
- .then((res) => res.json())
- .then((data) => {
- if (data.files) {
- const fileMap = data.files.reduce(
- (acc: Record, file: { id: string; name: string }) => {
- acc[file.id] = file.name
- return acc
- },
- {}
- )
- useDisplayNamesStore.getState().setDisplayNames('files', context.credentialId!, fileMap)
- }
- })
- .catch(() => {})
- .finally(() => setIsFetching(false))
- }
- // SharePoint sites
- else if (serviceId === 'sharepoint') {
- fetch(`/api/tools/sharepoint/sites?credentialId=${context.credentialId}`)
- .then((res) => res.json())
- .then((data) => {
- if (data.files) {
- const fileMap = data.files.reduce(
- (acc: Record, file: { id: string; name: string }) => {
- acc[file.id] = file.name
- return acc
- },
- {}
- )
- useDisplayNamesStore.getState().setDisplayNames('files', context.credentialId!, fileMap)
- }
- })
- .catch(() => {})
- .finally(() => setIsFetching(false))
- }
- // Microsoft Excel/Word
- else if (provider === 'microsoft-excel' || provider === 'microsoft-word') {
- fetch(`/api/auth/oauth/microsoft/files?credentialId=${context.credentialId}`)
- .then((res) => res.json())
- .then((data) => {
- if (data.files) {
- const fileMap = data.files.reduce(
- (acc: Record, file: { id: string; name: string }) => {
- acc[file.id] = file.name
- return acc
- },
- {}
- )
- useDisplayNamesStore.getState().setDisplayNames('files', context.credentialId!, fileMap)
- }
- })
- .catch(() => {})
- .finally(() => setIsFetching(false))
- }
- // Microsoft Planner tasks
- else if (provider === 'microsoft-planner' && context.planId) {
- fetch(
- `/api/tools/microsoft_planner/tasks?credentialId=${context.credentialId}&planId=${context.planId}`
- )
- .then((res) => res.json())
- .then((data) => {
- if (data.tasks) {
- const taskMap = data.tasks.reduce(
- (acc: Record, task: { id: string; title: string }) => {
- acc[task.id] = task.title
- return acc
- },
- {}
- )
- useDisplayNamesStore.getState().setDisplayNames('files', context.credentialId!, taskMap)
- }
- })
- .catch(() => {})
- .finally(() => setIsFetching(false))
- }
- // Google Drive files/folders (fetch by ID since no list endpoint via Picker API)
- else if (
- (provider === 'google-drive' || subBlock.serviceId === 'google-drive') &&
- typeof value === 'string' &&
- value
- ) {
- const queryParams = new URLSearchParams({
- credentialId: context.credentialId,
- fileId: value,
- })
- fetch(`/api/tools/drive/file?${queryParams.toString()}`)
- .then((res) => res.json())
- .then((data) => {
- if (data.file?.id && data.file.name) {
- useDisplayNamesStore
- .getState()
- .setDisplayNames('files', context.credentialId!, { [data.file.id]: data.file.name })
- }
- })
- .catch(() => {})
- .finally(() => setIsFetching(false))
- } else {
- setIsFetching(false)
- }
- }, [
- subBlock?.type,
- subBlock?.provider,
- subBlock?.serviceId,
- subBlock?.mimeType,
- value,
- context?.credentialId,
- context?.provider,
- context?.domain,
- context?.projectId,
- context?.teamId,
- context?.planId,
- ])
-
- if (!subBlock || !value || typeof value !== 'string') {
- return null
- }
-
- // Credentials - handled separately by useCredentialDisplay
- if (subBlock.type === 'oauth-input') {
- return null
- }
-
- // Knowledge Bases - use existing knowledge store
- if (subBlock.type === 'knowledge-base-selector') {
- const kb = getCachedKnowledgeBase(value)
- return kb?.name || null
- }
-
- // Return the cached display name (which triggers re-render when populated)
- return cachedDisplayName
-}
diff --git a/apps/sim/hooks/use-knowledge-base-name.ts b/apps/sim/hooks/use-knowledge-base-name.ts
new file mode 100644
index 00000000000..412dec2db25
--- /dev/null
+++ b/apps/sim/hooks/use-knowledge-base-name.ts
@@ -0,0 +1,22 @@
+import { useEffect, useState } from 'react'
+import { useKnowledgeStore } from '@/stores/knowledge/store'
+
+export function useKnowledgeBaseName(knowledgeBaseId?: string | null) {
+ const getCachedKnowledgeBase = useKnowledgeStore((state) => state.getCachedKnowledgeBase)
+ const getKnowledgeBase = useKnowledgeStore((state) => state.getKnowledgeBase)
+ const [isLoading, setIsLoading] = useState(false)
+
+ const cached = knowledgeBaseId ? getCachedKnowledgeBase(knowledgeBaseId) : null
+
+ useEffect(() => {
+ if (!knowledgeBaseId || cached || isLoading) return
+ setIsLoading(true)
+ getKnowledgeBase(knowledgeBaseId)
+ .catch(() => {
+ // ignore
+ })
+ .finally(() => setIsLoading(false))
+ }, [knowledgeBaseId, cached, isLoading, getKnowledgeBase])
+
+ return cached?.name ?? null
+}
diff --git a/apps/sim/hooks/use-selector-display-name.ts b/apps/sim/hooks/use-selector-display-name.ts
new file mode 100644
index 00000000000..91d6f7f8172
--- /dev/null
+++ b/apps/sim/hooks/use-selector-display-name.ts
@@ -0,0 +1,83 @@
+import { useMemo } from 'react'
+import type { SubBlockConfig } from '@/blocks/types'
+import { resolveSelectorForSubBlock } from '@/hooks/selectors/resolution'
+import type { SelectorKey } from '@/hooks/selectors/types'
+import {
+ useSelectorOptionDetail,
+ useSelectorOptionMap,
+ useSelectorOptions,
+} from '@/hooks/selectors/use-selector-query'
+
+interface SelectorDisplayNameArgs {
+ subBlock?: SubBlockConfig
+ value: unknown
+ workflowId?: string
+ credentialId?: string
+ domain?: string
+ projectId?: string
+ planId?: string
+ teamId?: string
+ knowledgeBaseId?: string
+}
+
+export function useSelectorDisplayName({
+ subBlock,
+ value,
+ workflowId,
+ credentialId,
+ domain,
+ projectId,
+ planId,
+ teamId,
+ knowledgeBaseId,
+}: SelectorDisplayNameArgs) {
+ const detailId = typeof value === 'string' && value.length > 0 ? value : undefined
+
+ const resolution = useMemo(() => {
+ if (!subBlock || !detailId) return null
+ return resolveSelectorForSubBlock(subBlock, {
+ workflowId,
+ credentialId,
+ domain,
+ projectId,
+ planId,
+ teamId,
+ knowledgeBaseId,
+ })
+ }, [
+ subBlock,
+ detailId,
+ workflowId,
+ credentialId,
+ domain,
+ projectId,
+ planId,
+ teamId,
+ knowledgeBaseId,
+ ])
+
+ const key = resolution?.key
+ const context = resolution?.context ?? {}
+ const enabled = Boolean(key && detailId)
+ const resolvedKey: SelectorKey = (key ?? 'slack.channels') as SelectorKey
+ const resolvedContext = enabled ? context : {}
+
+ const { data: options = [], isFetching: listLoading } = useSelectorOptions(resolvedKey, {
+ context: resolvedContext,
+ enabled,
+ })
+
+ const { data: detailOption, isLoading: detailLoading } = useSelectorOptionDetail(resolvedKey, {
+ context: resolvedContext,
+ detailId: enabled ? detailId : undefined,
+ enabled,
+ })
+
+ const optionMap = useSelectorOptionMap(options, detailOption ?? undefined)
+ const displayName = detailId ? (optionMap.get(detailId)?.label ?? null) : null
+
+ return {
+ displayName: enabled ? displayName : null,
+ isLoading: enabled ? listLoading || detailLoading : false,
+ }
+}
diff --git a/apps/sim/lib/idempotency/service.ts b/apps/sim/lib/idempotency/service.ts
index e20dc6dbfea..31495c2a998 100644
--- a/apps/sim/lib/idempotency/service.ts
+++ b/apps/sim/lib/idempotency/service.ts
@@ -4,6 +4,7 @@ import { idempotencyKey } from '@sim/db/schema'
import { and, eq } from 'drizzle-orm'
import { createLogger } from '@/lib/logs/console/logger'
import { getRedisClient } from '@/lib/redis'
+import { extractProviderIdentifierFromBody } from '@/lib/webhooks/provider-utils'
const logger = createLogger('IdempotencyService')
@@ -451,13 +452,25 @@ export class IdempotencyService {
/**
* Create an idempotency key from a webhook payload following RFC best practices
- * Standard webhook headers (webhook-id, x-webhook-id, etc.)
+ * Checks both headers and body for unique identifiers to prevent duplicate executions
+ *
+ * @param webhookId - The webhook database ID
+ * @param headers - HTTP headers from the webhook request
+ * @param body - Parsed webhook body (optional, used for provider-specific identifiers)
+ * @param provider - Provider name for body extraction (optional)
+ * @returns A unique idempotency key for this webhook event
*/
- static createWebhookIdempotencyKey(webhookId: string, headers?: Record): string {
+ static createWebhookIdempotencyKey(
+ webhookId: string,
+ headers?: Record,
+ body?: any,
+ provider?: string
+ ): string {
const normalizedHeaders = headers
? Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v]))
: undefined
+ // Check standard webhook headers first
const webhookIdHeader =
normalizedHeaders?.['webhook-id'] ||
normalizedHeaders?.['x-webhook-id'] ||
@@ -470,7 +483,22 @@ export class IdempotencyService {
return `${webhookId}:${webhookIdHeader}`
}
+ // Check body for provider-specific unique identifiers
+ if (body && provider) {
+ const bodyIdentifier = extractProviderIdentifierFromBody(provider, body)
+
+ if (bodyIdentifier) {
+ return `${webhookId}:${bodyIdentifier}`
+ }
+ }
+
+ // No unique identifier found - generate random UUID
+ // This means duplicate detection will not work for this webhook
const uniqueId = randomUUID()
+ logger.warn('No unique identifier found, duplicate executions may occur', {
+ webhookId,
+ provider,
+ })
return `${webhookId}:${uniqueId}`
}
}
diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts
index d26cfa7f16e..ff131effc82 100644
--- a/apps/sim/lib/oauth/oauth.ts
+++ b/apps/sim/lib/oauth/oauth.ts
@@ -906,6 +906,24 @@ export function parseProvider(provider: OAuthProvider): ProviderConfig {
featureType: 'sharepoint',
}
}
+ if (provider === 'microsoft-teams' || provider === 'microsoftteams') {
+ return {
+ baseProvider: 'microsoft',
+ featureType: 'microsoft-teams',
+ }
+ }
+ if (provider === 'microsoft-excel') {
+ return {
+ baseProvider: 'microsoft',
+ featureType: 'microsoft-excel',
+ }
+ }
+ if (provider === 'microsoft-planner') {
+ return {
+ baseProvider: 'microsoft',
+ featureType: 'microsoft-planner',
+ }
+ }
// Handle compound providers (e.g., 'google-email' -> { baseProvider: 'google', featureType: 'email' })
const [base, feature] = provider.split('-')
diff --git a/apps/sim/lib/webhooks/processor.ts b/apps/sim/lib/webhooks/processor.ts
index 1b76610096e..5d58b36d065 100644
--- a/apps/sim/lib/webhooks/processor.ts
+++ b/apps/sim/lib/webhooks/processor.ts
@@ -250,7 +250,7 @@ export async function verifyProviderAuth(
const rawProviderConfig = (foundWebhook.providerConfig as Record) || {}
const providerConfig = resolveProviderConfigEnvVars(rawProviderConfig, decryptedEnvVars)
- if (foundWebhook.provider === 'microsoftteams') {
+ if (foundWebhook.provider === 'microsoft-teams') {
if (providerConfig.hmacSecret) {
const authHeader = request.headers.get('authorization')
@@ -556,7 +556,7 @@ export async function checkRateLimits(
traceSpans: [],
})
- if (foundWebhook.provider === 'microsoftteams') {
+ if (foundWebhook.provider === 'microsoft-teams') {
return NextResponse.json(
{
type: 'message',
@@ -634,7 +634,7 @@ export async function checkUsageLimits(
traceSpans: [],
})
- if (foundWebhook.provider === 'microsoftteams') {
+ if (foundWebhook.provider === 'microsoft-teams') {
return NextResponse.json(
{
type: 'message',
@@ -783,7 +783,7 @@ export async function queueWebhookExecution(
// For Microsoft Teams Graph notifications, extract unique identifiers for idempotency
if (
- foundWebhook.provider === 'microsoftteams' &&
+ foundWebhook.provider === 'microsoft-teams' &&
body?.value &&
Array.isArray(body.value) &&
body.value.length > 0
@@ -835,7 +835,7 @@ export async function queueWebhookExecution(
)
}
- if (foundWebhook.provider === 'microsoftteams') {
+ if (foundWebhook.provider === 'microsoft-teams') {
const providerConfig = (foundWebhook.providerConfig as Record) || {}
const triggerId = providerConfig.triggerId as string | undefined
@@ -886,7 +886,7 @@ export async function queueWebhookExecution(
} catch (error: any) {
logger.error(`[${options.requestId}] Failed to queue webhook execution:`, error)
- if (foundWebhook.provider === 'microsoftteams') {
+ if (foundWebhook.provider === 'microsoft-teams') {
return NextResponse.json(
{
type: 'message',
diff --git a/apps/sim/lib/webhooks/provider-utils.ts b/apps/sim/lib/webhooks/provider-utils.ts
new file mode 100644
index 00000000000..2ae0681e941
--- /dev/null
+++ b/apps/sim/lib/webhooks/provider-utils.ts
@@ -0,0 +1,85 @@
+/**
+ * Provider-specific unique identifier extractors for webhook idempotency
+ */
+
+function extractSlackIdentifier(body: any): string | null {
+ if (body.event_id) {
+ return body.event_id
+ }
+
+ if (body.event?.ts && body.team_id) {
+ return `${body.team_id}:${body.event.ts}`
+ }
+
+ return null
+}
+
+function extractTwilioIdentifier(body: any): string | null {
+ return body.MessageSid || body.CallSid || null
+}
+
+function extractStripeIdentifier(body: any): string | null {
+ if (body.id && body.object === 'event') {
+ return body.id
+ }
+ return null
+}
+
+function extractHubSpotIdentifier(body: any): string | null {
+ if (Array.isArray(body) && body.length > 0 && body[0]?.eventId) {
+ return String(body[0].eventId)
+ }
+ return null
+}
+
+function extractLinearIdentifier(body: any): string | null {
+ if (body.action && body.data?.id) {
+ return `${body.action}:${body.data.id}`
+ }
+ return null
+}
+
+function extractJiraIdentifier(body: any): string | null {
+ if (body.webhookEvent && (body.issue?.id || body.project?.id)) {
+ return `${body.webhookEvent}:${body.issue?.id || body.project?.id}`
+ }
+ return null
+}
+
+function extractMicrosoftTeamsIdentifier(body: any): string | null {
+ if (body.value && Array.isArray(body.value) && body.value.length > 0) {
+ const notification = body.value[0]
+ if (notification.subscriptionId && notification.resourceData?.id) {
+ return `${notification.subscriptionId}:${notification.resourceData.id}`
+ }
+ }
+ return null
+}
+
+function extractAirtableIdentifier(body: any): string | null {
+ if (body.cursor && typeof body.cursor === 'string') {
+ return body.cursor
+ }
+ return null
+}
+
+const PROVIDER_EXTRACTORS: Record string | null> = {
+ slack: extractSlackIdentifier,
+ twilio: extractTwilioIdentifier,
+ twilio_voice: extractTwilioIdentifier,
+ stripe: extractStripeIdentifier,
+ hubspot: extractHubSpotIdentifier,
+ linear: extractLinearIdentifier,
+ jira: extractJiraIdentifier,
+ 'microsoft-teams': extractMicrosoftTeamsIdentifier,
+ airtable: extractAirtableIdentifier,
+}
+
+export function extractProviderIdentifierFromBody(provider: string, body: any): string | null {
+ if (!body || typeof body !== 'object') {
+ return null
+ }
+
+ const extractor = PROVIDER_EXTRACTORS[provider]
+ return extractor ? extractor(body) : null
+}
diff --git a/apps/sim/lib/webhooks/utils.server.ts b/apps/sim/lib/webhooks/utils.server.ts
index d21867e1890..47aacdba32d 100644
--- a/apps/sim/lib/webhooks/utils.server.ts
+++ b/apps/sim/lib/webhooks/utils.server.ts
@@ -133,7 +133,7 @@ async function formatTeamsGraphNotification(
input: 'Teams notification received',
webhook: {
data: {
- provider: 'microsoftteams',
+ provider: 'microsoft-teams',
path: foundWebhook?.path || '',
providerConfig: foundWebhook?.providerConfig || {},
payload: body,
@@ -397,7 +397,7 @@ async function formatTeamsGraphNotification(
},
webhook: {
data: {
- provider: 'microsoftteams',
+ provider: 'microsoft-teams',
path: foundWebhook?.path || '',
providerConfig: foundWebhook?.providerConfig || {},
payload: body,
@@ -446,7 +446,7 @@ async function formatTeamsGraphNotification(
},
webhook: {
data: {
- provider: 'microsoftteams',
+ provider: 'microsoft-teams',
path: foundWebhook?.path || '',
providerConfig: foundWebhook?.providerConfig || {},
payload: body,
@@ -818,7 +818,7 @@ export async function formatWebhookInput(
}
}
- if (foundWebhook.provider === 'microsoftteams') {
+ if (foundWebhook.provider === 'microsoft-teams') {
// Check if this is a Microsoft Graph change notification
if (body?.value && Array.isArray(body.value) && body.value.length > 0) {
return await formatTeamsGraphNotification(body, foundWebhook, foundWorkflow, request)
@@ -875,7 +875,7 @@ export async function formatWebhookInput(
webhook: {
data: {
- provider: 'microsoftteams',
+ provider: 'microsoft-teams',
path: foundWebhook.path,
providerConfig: foundWebhook.providerConfig,
payload: body,
@@ -1653,7 +1653,7 @@ export function verifyProviderWebhook(
break
}
- case 'microsoftteams':
+ case 'microsoft-teams':
break
case 'generic':
if (providerConfig.requireAuth) {
diff --git a/apps/sim/lib/webhooks/webhook-helpers.ts b/apps/sim/lib/webhooks/webhook-helpers.ts
index c1ccf976b74..9d9d6775a1f 100644
--- a/apps/sim/lib/webhooks/webhook-helpers.ts
+++ b/apps/sim/lib/webhooks/webhook-helpers.ts
@@ -623,7 +623,7 @@ export async function cleanupExternalWebhook(
): Promise {
if (webhook.provider === 'airtable') {
await deleteAirtableWebhook(webhook, workflow, requestId)
- } else if (webhook.provider === 'microsoftteams') {
+ } else if (webhook.provider === 'microsoft-teams') {
await deleteTeamsSubscription(webhook, workflow, requestId)
} else if (webhook.provider === 'telegram') {
await deleteTelegramWebhook(webhook, requestId)
diff --git a/apps/sim/stores/display-names/store.ts b/apps/sim/stores/display-names/store.ts
deleted file mode 100644
index 9b889c2bbbe..00000000000
--- a/apps/sim/stores/display-names/store.ts
+++ /dev/null
@@ -1,143 +0,0 @@
-import { create } from 'zustand'
-import { createLogger } from '@/lib/logs/console/logger'
-
-const logger = createLogger('DisplayNamesStore')
-
-/**
- * Generic cache for ID-to-name mappings for all selector types
- * Structure: { type: { context: { id: name } } }
- *
- */
-interface DisplayNamesCache {
- credentials: Record> // provider -> id -> name
- channels: Record> // credentialContext -> id -> name
- knowledgeBases: Record> // workspaceId -> id -> name
- workflows: Record> // always 'global' -> id -> name
- files: Record> // credentialContext -> id -> name
- folders: Record> // credentialContext -> id -> name
- projects: Record> // provider-credential -> id -> name
- documents: Record> // knowledgeBaseId -> id -> name
-}
-
-interface DisplayNamesStore {
- cache: DisplayNamesCache
-
- /**
- * Set a display name for an ID
- */
- setDisplayName: (type: keyof DisplayNamesCache, context: string, id: string, name: string) => void
-
- /**
- * Set multiple display names at once
- */
- setDisplayNames: (
- type: keyof DisplayNamesCache,
- context: string,
- items: Record
- ) => void
-
- /**
- * Get a display name for an ID
- */
- getDisplayName: (type: keyof DisplayNamesCache, context: string, id: string) => string | null
-
- /**
- * Remove a single display name
- */
- removeDisplayName: (type: keyof DisplayNamesCache, context: string, id: string) => void
-
- /**
- * Clear all cached display names for a type/context
- */
- clearContext: (type: keyof DisplayNamesCache, context: string) => void
-
- /**
- * Clear all cached display names
- */
- clearAll: () => void
-}
-
-const initialCache: DisplayNamesCache = {
- credentials: {},
- channels: {},
- knowledgeBases: {},
- workflows: {},
- files: {},
- folders: {},
- projects: {},
- documents: {},
-}
-
-export const useDisplayNamesStore = create((set, get) => ({
- cache: initialCache,
-
- setDisplayName: (type, context, id, name) => {
- set((state) => ({
- cache: {
- ...state.cache,
- [type]: {
- ...state.cache[type],
- [context]: {
- ...state.cache[type][context],
- [id]: name,
- },
- },
- },
- }))
- },
-
- setDisplayNames: (type, context, items) => {
- set((state) => ({
- cache: {
- ...state.cache,
- [type]: {
- ...state.cache[type],
- [context]: {
- ...state.cache[type][context],
- ...items,
- },
- },
- },
- }))
-
- logger.info(`Cached ${Object.keys(items).length} display names`, { type, context })
- },
-
- getDisplayName: (type, context, id) => {
- const contextCache = get().cache[type][context]
- return contextCache?.[id] || null
- },
-
- removeDisplayName: (type, context, id) => {
- set((state) => {
- const contextCache = { ...state.cache[type][context] }
- delete contextCache[id]
- return {
- cache: {
- ...state.cache,
- [type]: {
- ...state.cache[type],
- [context]: contextCache,
- },
- },
- }
- })
- },
-
- clearContext: (type, context) => {
- set((state) => {
- const newTypeCache = { ...state.cache[type] }
- delete newTypeCache[context]
- return {
- cache: {
- ...state.cache,
- [type]: newTypeCache,
- },
- }
- })
- },
-
- clearAll: () => {
- set({ cache: initialCache })
- },
-}))
diff --git a/apps/sim/stores/undo-redo/store.ts b/apps/sim/stores/undo-redo/store.ts
index 62afff627ee..c57afbe2cc8 100644
--- a/apps/sim/stores/undo-redo/store.ts
+++ b/apps/sim/stores/undo-redo/store.ts
@@ -13,7 +13,7 @@ import type {
} from './types'
const logger = createLogger('UndoRedoStore')
-const DEFAULT_CAPACITY = 15
+const DEFAULT_CAPACITY = 100
function getStackKey(workflowId: string, userId: string): string {
return `${workflowId}:${userId}`