diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx index bb5a4180da8..ab044210df6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx @@ -269,10 +269,10 @@ export function Chat() { const { addToQueue } = useOperationQueue() const [chatMessage, setChatMessage] = useState('') - const [promptHistory, setPromptHistory] = useState([]) - const [historyIndex, setHistoryIndex] = useState(-1) const [moreMenuOpen, setMoreMenuOpen] = useState(false) + const promptHistoryRef = useRef([]) + const historyIndexRef = useRef(-1) const inputRef = useRef(null) const timeoutRef = useRef(null) const streamReaderRef = useRef | null>(null) @@ -292,22 +292,26 @@ export function Chat() { handleDrop, } = useChatFileUpload() - const filePreviewUrls = useRef>(new Map()) + const filePreviewUrls = useRef | null>(null) + if (filePreviewUrls.current === null) { + filePreviewUrls.current = new Map() + } const getFilePreviewUrl = useCallback((file: ChatFile): string | null => { if (!file.type.startsWith('image/')) return null - const existing = filePreviewUrls.current.get(file.id) + const urls = (filePreviewUrls.current ??= new Map()) + const existing = urls.get(file.id) if (existing) return existing const url = URL.createObjectURL(file.file) - filePreviewUrls.current.set(file.id, url) + urls.set(file.id, url) return url }, []) useEffect(() => { const currentFileIds = new Set(chatFiles.map((f) => f.id)) - const urlMap = filePreviewUrls.current + const urlMap = (filePreviewUrls.current ??= new Map()) for (const [fileId, url] of urlMap.entries()) { if (!currentFileIds.has(fileId)) { @@ -454,21 +458,24 @@ export function Chat() { ) const userMessages = useMemo(() => { - return workflowMessages - .filter((msg) => msg.type === 'user') - .map((msg) => msg.content) - .filter((content): content is string => typeof content === 'string') + const result: string[] = [] + for (const msg of workflowMessages) { + if (msg.type === 'user' && typeof msg.content === 'string') { + result.push(msg.content) + } + } + return result }, [workflowMessages]) useEffect(() => { if (!activeWorkflowId) { - setPromptHistory([]) - setHistoryIndex(-1) + promptHistoryRef.current = [] + historyIndexRef.current = -1 return } - setPromptHistory(userMessages) - setHistoryIndex(-1) + promptHistoryRef.current = userMessages + historyIndexRef.current = -1 }, [activeWorkflowId, userMessages]) /** @@ -607,7 +614,7 @@ export function Chat() { focusInput(100) } }, - [appendMessageContent, finalizeMessageStream, focusInput, selectedOutputs, activeWorkflowId] + [appendMessageContent, finalizeMessageStream, focusInput] ) /** @@ -633,19 +640,18 @@ export function Chat() { } if ('success' in result && result.success && 'logs' in result && Array.isArray(result.logs)) { - selectedOutputs - .map((outputId) => extractOutputFromLogs(result.logs as BlockLog[], outputId)) - .filter((output) => output !== undefined) - .forEach((output) => { - const content = formatOutputContent(output) - if (content) { - addMessage({ - content, - workflowId: activeWorkflowId, - type: 'workflow', - }) - } - }) + for (const outputId of selectedOutputs) { + const output = extractOutputFromLogs(result.logs as BlockLog[], outputId) + if (output === undefined) continue + const content = formatOutputContent(output) + if (content) { + addMessage({ + content, + workflowId: activeWorkflowId, + type: 'workflow', + }) + } + } return } @@ -674,10 +680,13 @@ export function Chat() { const sentMessage = chatMessage.trim() - if (sentMessage && promptHistory[promptHistory.length - 1] !== sentMessage) { - setPromptHistory((prev) => [...prev, sentMessage]) + if ( + sentMessage && + promptHistoryRef.current[promptHistoryRef.current.length - 1] !== sentMessage + ) { + promptHistoryRef.current = [...promptHistoryRef.current, sentMessage] } - setHistoryIndex(-1) + historyIndexRef.current = -1 const conversationId = getConversationId(activeWorkflowId) @@ -732,7 +741,6 @@ export function Chat() { chatFiles, activeWorkflowId, isExecuting, - promptHistory, getConversationId, addMessage, handleRunWorkflow, @@ -756,27 +764,29 @@ export function Chat() { } } else if (e.key === 'ArrowUp') { e.preventDefault() - if (promptHistory.length > 0) { + if (promptHistoryRef.current.length > 0) { const newIndex = - historyIndex === -1 ? promptHistory.length - 1 : Math.max(0, historyIndex - 1) - setHistoryIndex(newIndex) - setChatMessage(promptHistory[newIndex]) + historyIndexRef.current === -1 + ? promptHistoryRef.current.length - 1 + : Math.max(0, historyIndexRef.current - 1) + historyIndexRef.current = newIndex + setChatMessage(promptHistoryRef.current[newIndex]) } } else if (e.key === 'ArrowDown') { e.preventDefault() - if (historyIndex >= 0) { - const newIndex = historyIndex + 1 - if (newIndex >= promptHistory.length) { - setHistoryIndex(-1) + if (historyIndexRef.current >= 0) { + const newIndex = historyIndexRef.current + 1 + if (newIndex >= promptHistoryRef.current.length) { + historyIndexRef.current = -1 setChatMessage('') } else { - setHistoryIndex(newIndex) - setChatMessage(promptHistory[newIndex]) + historyIndexRef.current = newIndex + setChatMessage(promptHistoryRef.current[newIndex]) } } } }, - [handleSendMessage, promptHistory, historyIndex, isStreaming, isExecuting] + [handleSendMessage, isStreaming, isExecuting] ) /** @@ -1085,7 +1095,7 @@ export function Chat() { value={chatMessage} onChange={(e) => { setChatMessage(e.target.value) - setHistoryIndex(-1) + historyIndexRef.current = -1 }} onKeyDown={handleKeyPress} placeholder={isDragOver ? 'Drop files here...' : 'Type a message...'} @@ -1122,6 +1132,7 @@ export function Chat() { ) : ( + + + + ) +} + export function DocumentTagEntry({ blockId, subBlock, @@ -98,29 +188,19 @@ export function DocumentTagEntry({ const currentValue = isPreview ? previewValue : storeValue - const parseTags = (tagValue: string | null): DocumentTag[] => { - if (!tagValue) return [] - try { - const parsed = JSON.parse(tagValue) - if (!Array.isArray(parsed)) return [] - return parsed.map((t: DocumentTag) => ({ - ...t, - fieldType: t.fieldType || 'text', - collapsed: t.collapsed ?? false, - })) - } catch { - return [] - } - } - const parsedTags = parseTags(currentValue || null) const tags: DocumentTag[] = parsedTags.length > 0 ? parsedTags : [createDefaultTag()] const isReadOnly = isPreview || disabled - // Get tag names already used (case-insensitive) - const usedTagNames = useMemo(() => { - return new Set(tags.map((t) => t.tagName?.toLowerCase()).filter((name) => name?.trim())) - }, [tags]) + /** Tag names already in use (case-insensitive); computed in render since `tags` is rebuilt each render. */ + const usedTagNames = (() => { + const names = new Set() + for (const t of tags) { + const name = t.tagName?.toLowerCase() + if (name?.trim()) names.add(name) + } + return names + })() // Filter available tags (exclude already used ones) const availableTagDefinitions = useMemo(() => { @@ -241,58 +321,6 @@ export function DocumentTagEntry({ ) } - /** - * Renders the tag header with name, badge, and action buttons - * Shows tag name only when collapsed (as summary), generic label when expanded - */ - const renderTagHeader = (tag: DocumentTag, index: number) => ( -
toggleCollapse(tag.id)} - onKeyDown={(event) => { - if (event.target !== event.currentTarget) return - handleKeyboardActivation(event, () => toggleCollapse(tag.id)) - }} - > -
- - {tag.collapsed ? tag.tagName || `Tag ${index + 1}` : `Tag ${index + 1}`} - - {tag.collapsed && tag.tagName && ( - - {FIELD_TYPE_LABELS[tag.fieldType] || 'Text'} - - )} -
-
e.stopPropagation()} - > - - -
-
- ) - /** * Renders the value input with tag dropdown support */ @@ -423,7 +451,15 @@ export function DocumentTagEntry({ tag.collapsed ? 'overflow-hidden' : 'overflow-visible' )} > - {renderTagHeader(tag, index)} + toggleCollapse(tag.id)} + onAdd={addTag} + onRemove={() => removeTag(tag.id)} + /> {!tag.collapsed && renderTagContent(tag)} ))} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx index d4f831d559f..c1cc05aa2f4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx @@ -22,6 +22,28 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store' /** Selected-value badges shown before folding the rest into a "+N" badge. */ const MAX_VISIBLE_MULTI_SELECT_BADGES = 2 +/** + * Normalizes variable references in JSON strings by wrapping them in quotes + * @param jsonString - The JSON string containing variable references + * @returns Normalized JSON string with quoted variable references + */ +const normalizeVariableReferences = (jsonString: string): string => { + return jsonString.replace(/([^"]<[^>]+>)/g, '"$1"') +} + +/** + * Infers the type of a value for builder data field configuration + * @param value - The value to infer type from + * @returns The inferred type as a string literal + */ +const inferType = (value: any): 'string' | 'number' | 'boolean' | 'object' | 'array' => { + if (typeof value === 'boolean') return 'boolean' + if (typeof value === 'number') return 'number' + if (Array.isArray(value)) return 'array' + if (typeof value === 'object' && value !== null) return 'object' + return 'string' +} + /** * Dropdown option type - can be a simple string or an object with label, id, and optional icon. * Options with `hidden: true` are excluded from the picker but still resolve for label display, @@ -153,13 +175,10 @@ export const Dropdown = memo(function Dropdown({ const value = isPreview ? previewValue : propValue !== undefined ? propValue : storeValue const singleValue = multiSelect ? null : (value as string | null | undefined) - const multiValues = multiSelect - ? Array.isArray(value) - ? value - : value - ? [value as string] - : [] - : null + const multiValues = useMemo( + () => (multiSelect ? (Array.isArray(value) ? value : value ? [value as string] : []) : null), + [multiSelect, value] + ) const fetchOptionsIfNeeded = useCallback(async () => { if (!fetchOptions || isPreview || disabled) return @@ -281,15 +300,6 @@ export const Dropdown = memo(function Dropdown({ } }, [storeValue, defaultOptionValue, setStoreValue, multiSelect]) - /** - * Normalizes variable references in JSON strings by wrapping them in quotes - * @param jsonString - The JSON string containing variable references - * @returns Normalized JSON string with quoted variable references - */ - const normalizeVariableReferences = (jsonString: string): string => { - return jsonString.replace(/([^"]<[^>]+>)/g, '"$1"') - } - /** * Converts a JSON string to builder data format for structured editing * @param jsonString - The JSON string to convert @@ -322,19 +332,6 @@ export const Dropdown = memo(function Dropdown({ } } - /** - * Infers the type of a value for builder data field configuration - * @param value - The value to infer type from - * @returns The inferred type as a string literal - */ - const inferType = (value: any): 'string' | 'number' | 'boolean' | 'object' | 'array' => { - if (typeof value === 'boolean') return 'boolean' - if (typeof value === 'number') return 'number' - if (Array.isArray(value)) return 'array' - if (typeof value === 'object' && value !== null) return 'object' - return 'string' - } - useEffect(() => { if (multiSelect || subBlockId !== 'dataMode' || isPreview || disabled) return diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown/env-var-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown/env-var-dropdown.tsx index dfa0412e510..d2ba2739aca 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown/env-var-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown/env-var-dropdown.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { cn, Popover, @@ -134,6 +134,7 @@ export const EnvVarDropdown: React.FC = ({ const userEnvVars = Object.keys(personalEnv) const [selectedIndex, setSelectedIndex] = useState(0) + const prevSearchTermRef = useRef(searchTerm) const envVarGroups: EnvVarGroup[] = [] @@ -155,18 +156,17 @@ export const EnvVarDropdown: React.FC = ({ envVar.toLowerCase().includes(searchTerm.toLowerCase()) ) - const filteredGroups = envVarGroups - .map((group) => ({ - ...group, - variables: group.variables.filter((envVar) => - envVar.toLowerCase().includes(searchTerm.toLowerCase()) - ), - })) - .filter((group) => group.variables.length > 0) + const filteredGroups = envVarGroups.flatMap((group) => { + const variables = group.variables.filter((envVar) => + envVar.toLowerCase().includes(searchTerm.toLowerCase()) + ) + return variables.length > 0 ? [{ ...group, variables }] : [] + }) - useEffect(() => { + if (prevSearchTermRef.current !== searchTerm) { + prevSearchTermRef.current = searchTerm setSelectedIndex(0) - }, [searchTerm]) + } const openEnvironmentSettings = () => { if (workspaceId) { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/eval-input/eval-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/eval-input/eval-input.tsx index 621e07b38e9..de1e977a54d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/eval-input/eval-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/eval-input/eval-input.tsx @@ -37,6 +37,56 @@ const createDefaultMetric = (): EvalMetric => ({ range: { min: 0, max: 1 }, }) +interface MetricHeaderProps { + index: number + metricId: string + disableAdd: boolean + disableRemove: boolean + onAdd: () => void + onRemove: () => void +} + +function MetricHeader({ + index, + metricId, + disableAdd, + disableRemove, + onAdd, + onRemove, +}: MetricHeaderProps) { + return ( +
+ Metric {index + 1} +
+ + + + + Add Metric + + + + + + + Delete Metric + +
+
+ ) +} + export function EvalInput({ blockId, subBlockId, @@ -76,8 +126,6 @@ export function EvalInput({ valuePath: [metricIndex, ...metricPath], }) - const renderFieldLabel = (label: string) => - const addMetric = () => { if (isPreview || disabled) return @@ -138,43 +186,6 @@ export function EvalInput({ updateMetric(metricId, 'description', newDescription) } - const renderMetricHeader = (metric: EvalMetric, index: number) => ( -
- Metric {index + 1} -
- - - - - Add Metric - - - - - - - Delete Metric - -
-
- ) - return (
{metrics.map((metric, index) => ( @@ -183,11 +194,18 @@ export function EvalInput({ data-metric-id={metric.id} className='group relative overflow-visible rounded-sm border border-[var(--border-1)]' > - {renderMetricHeader(metric, index)} + removeMetric(metric.id)} + />
- {renderFieldLabel('Name')} +
- {renderFieldLabel('Description')} +
{(() => { const fieldState = inputController.fieldHelpers.getFieldState(metric.id) @@ -290,7 +308,7 @@ export function EvalInput({
- {renderFieldLabel('Min Value')} +
- {renderFieldLabel('Max Value')} +
{ + if (accepted === '*') return true + if (!fileType) return false + + const acceptedList = accepted.split(',').map((t) => t.trim().toLowerCase()) + const normalizedFileType = fileType.toLowerCase() + + return acceptedList.some((acceptedType) => { + if (acceptedType === normalizedFileType) return true + + if (acceptedType.endsWith('/*')) { + const typePrefix = acceptedType.slice(0, -1) // 'image/' from 'image/*' + return normalizedFileType.startsWith(typePrefix) + } + + if (acceptedType.startsWith('.')) { + const extension = acceptedType.slice(1).toLowerCase() + const fileExtension = getExtensionFromMimeType(normalizedFileType) + if (fileExtension === extension) return true + return normalizedFileType.endsWith(`/${extension}`) + } + + return false + }) +} + +/** + * Formats file size for display in a human-readable format + */ +const formatFileSize = (bytes: number): string => { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB` + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` +} + +/** + * Truncate long file names keeping both start and end segments. + */ +const truncateMiddle = (text: string, start = 28, end = 18) => { + if (!text) return '' + if (text.length <= start + end + 3) return text + return `${text.slice(0, start)}...${text.slice(-end)}` +} + interface FileUploadProps { blockId: string subBlockId: string @@ -228,36 +276,6 @@ export function FileUpload({ }, [modelValue, maxSize]) const maxSizeLabel = `${Math.round(maxSizeInBytes / (1024 * 1024))}MB` - /** - * Checks if a file's MIME type matches the accepted types - * Supports exact matches, wildcard patterns (e.g., 'image/*'), and '*' for all types - */ - const isFileTypeAccepted = (fileType: string | undefined, accepted: string): boolean => { - if (accepted === '*') return true - if (!fileType) return false - - const acceptedList = accepted.split(',').map((t) => t.trim().toLowerCase()) - const normalizedFileType = fileType.toLowerCase() - - return acceptedList.some((acceptedType) => { - if (acceptedType === normalizedFileType) return true - - if (acceptedType.endsWith('/*')) { - const typePrefix = acceptedType.slice(0, -1) // 'image/' from 'image/*' - return normalizedFileType.startsWith(typePrefix) - } - - if (acceptedType.startsWith('.')) { - const extension = acceptedType.slice(1).toLowerCase() - const fileExtension = getExtensionFromMimeType(normalizedFileType) - if (fileExtension === extension) return true - return normalizedFileType.endsWith(`/${extension}`) - } - - return false - }) - } - const availableWorkspaceFiles = workspaceFiles.filter((workspaceFile) => { const existingFiles = Array.isArray(value) ? value : value ? [value] : [] @@ -286,24 +304,6 @@ export function FileUpload({ } } - /** - * Formats file size for display in a human-readable format - */ - const formatFileSize = (bytes: number): string => { - if (bytes < 1024) return `${bytes} B` - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB` - return `${(bytes / (1024 * 1024)).toFixed(1)} MB` - } - - /** - * Truncate long file names keeping both start and end segments. - */ - const truncateMiddle = (text: string, start = 28, end = 18) => { - if (!text) return '' - if (text.length <= start + end + 3) return text - return `${text.slice(0, start)}...${text.slice(-end)}` - } - /** * Handles file upload when new file(s) are selected */ @@ -632,8 +632,8 @@ export function FileUpload({ [workspaceFiles, acceptedTypes] ) - // Find the selected file's workspace ID for highlighting in single file mode - const selectedFileId = useMemo(() => { + /** Selected file's workspace id for single-file highlighting; computed in render since `filesArray` is rebuilt each render. */ + const selectedFileId = (() => { if (!hasFiles || multiple) return '' const currentFile = filesArray[0] if (!currentFile) return '' @@ -645,7 +645,7 @@ export function FileUpload({ currentFile.path?.includes(wf.key) ) return matchedWorkspaceFile?.id || '' - }, [filesArray, workspaceFiles, hasFiles, multiple]) + })() const handleComboboxChange = (value: string) => { setInputValue(value) @@ -685,6 +685,7 @@ export function FileUpload({ style={{ display: 'none' }} accept={acceptedTypes} multiple={multiple} + aria-label='Upload file' data-testid='file-input-element' /> diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/filter-builder/filter-builder.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/filter-builder/filter-builder.tsx index 4075a2f72de..2c78e70446a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/filter-builder/filter-builder.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/filter-builder/filter-builder.tsx @@ -12,6 +12,9 @@ import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/c import { useActiveSearchTarget } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider' import { FilterRuleRow } from './components/filter-rule-row' +/** Stable empty-rules reference so the derived `rules` identity only changes when the store value does. */ +const EMPTY_RULES: FilterRule[] = [] + interface FilterBuilderProps { blockId: string subBlockId: string @@ -43,7 +46,7 @@ export function FilterBuilder({ }, [propColumns, dynamicColumns]) const value = isPreview ? previewValue : storeValue - const rules: FilterRule[] = Array.isArray(value) ? value : [] + const rules: FilterRule[] = Array.isArray(value) ? value : EMPTY_RULES const isReadOnly = isPreview || disabled const { comparisonOptions, logicalOptions, addRule, removeRule, updateRule } = useFilterBuilder({ diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/grouped-checkbox-list/grouped-checkbox-list.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/grouped-checkbox-list/grouped-checkbox-list.tsx index ac2673216a3..9744af9008e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/grouped-checkbox-list/grouped-checkbox-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/grouped-checkbox-list/grouped-checkbox-list.tsx @@ -65,6 +65,7 @@ export function GroupedCheckboxList({ const [open, setOpen] = useState(false) const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId) const optionRefs = useRef>({}) + const prevSearchTargetRef = useRef(null) const previewValue = isPreview && subBlockValues ? subBlockValues[subBlockId]?.value : undefined const selectedValues = ((isPreview ? previewValue : storeValue) as string[]) || [] @@ -108,11 +109,12 @@ export function GroupedCheckboxList({ const allSelected = selectedValues.length === options.length const noneSelected = selectedValues.length === 0 - useEffect(() => { + if (prevSearchTargetRef.current !== activeSearchTarget) { + prevSearchTargetRef.current = activeSearchTarget if (activeSearchTarget?.subBlockId === subBlockId) { setOpen(true) } - }, [activeSearchTarget, subBlockId]) + } useEffect(() => { if (!open || activeSearchTarget?.subBlockId !== subBlockId) return diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/input-mapping/input-mapping.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/input-mapping/input-mapping.tsx index 721e657cefc..81bcfd59775 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/input-mapping/input-mapping.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/input-mapping/input-mapping.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react' +import { useMemo, useRef, useState } from 'react' import { Badge, CollapsibleCard, cn, Input, Label } from '@sim/emcn' import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format' import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text' @@ -121,12 +121,17 @@ export function InputMapping({ })) } - useEffect(() => { - if (activeSearchTarget?.subBlockId !== subBlockId) return + /** + * Expand a collapsed field during render when the active search target points at it, so the + * highlighted match is visible. Guarded on the field's collapsed state, this converges after a + * single re-render instead of forcing an extra commit with the stale (collapsed) UI. + */ + if (activeSearchTarget?.subBlockId === subBlockId) { const [fieldName] = activeSearchTarget.valuePath - if (typeof fieldName !== 'string' || !collapsedFields[fieldName]) return - setCollapsedFields((prev) => ({ ...prev, [fieldName]: false })) - }, [activeSearchTarget, collapsedFields, subBlockId]) + if (typeof fieldName === 'string' && collapsedFields[fieldName]) { + setCollapsedFields((prev) => ({ ...prev, [fieldName]: false })) + } + } if (!selectedWorkflowId) { return ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-tag-filters/knowledge-tag-filters.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-tag-filters/knowledge-tag-filters.tsx index db9ee31e574..39a5b4e36e2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-tag-filters/knowledge-tag-filters.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-tag-filters/knowledge-tag-filters.tsx @@ -60,6 +60,25 @@ const createDefaultFilter = (): TagFilter => ({ collapsed: false, }) +/** + * Parses a JSON filter value into a normalized array of tag filters + */ +const parseFilters = (filterValue: string | null): TagFilter[] => { + if (!filterValue) return [] + try { + const parsed = JSON.parse(filterValue) + if (!Array.isArray(parsed)) return [] + return parsed.map((f: TagFilter) => ({ + ...f, + fieldType: f.fieldType || 'text', + operator: f.operator || 'eq', + collapsed: f.collapsed ?? false, + })) + } catch { + return [] + } +} + export function KnowledgeTagFilters({ blockId, subBlock, @@ -100,22 +119,6 @@ export function KnowledgeTagFilters({ disabled, }) - const parseFilters = (filterValue: string | null): TagFilter[] => { - if (!filterValue) return [] - try { - const parsed = JSON.parse(filterValue) - if (!Array.isArray(parsed)) return [] - return parsed.map((f: TagFilter) => ({ - ...f, - fieldType: f.fieldType || 'text', - operator: f.operator || 'eq', - collapsed: f.collapsed ?? false, - })) - } catch { - return [] - } - } - const currentValue = isPreview ? previewValue : storeValue const parsedFilters = parseFilters(currentValue || null) const filters: TagFilter[] = parsedFilters.length > 0 ? parsedFilters : [createDefaultFilter()] diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/long-input/long-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/long-input/long-input.tsx index 4614a496560..484dc936400 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/long-input/long-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/long-input/long-input.tsx @@ -40,6 +40,12 @@ const ROW_HEIGHT_PX = 24 */ const MIN_HEIGHT_PX = 80 +/** + * Stable empty default for the optional workflow search value path, so the + * default prop value does not create a new array reference on every render. + */ +const EMPTY_SEARCH_VALUE_PATH: Array = [] + /** * Props for the LongInput component */ @@ -94,7 +100,7 @@ export function LongInput({ disabled, wandControlRef, hideInternalWand = false, - workflowSearchValuePath = [], + workflowSearchValuePath = EMPTY_SEARCH_VALUE_PATH, }: LongInputProps) { const activeSearchTarget = useActiveSearchTarget() // Local state for immediate UI updates during streaming @@ -198,15 +204,16 @@ export function LongInput({ ? propValue : ctrl.valueString - // Sync local content with base value when not streaming - useEffect(() => { - if (!wandHook.isStreaming) { - setLocalContent((prev) => { - const baseValueString = baseValue?.toString() ?? '' - return baseValueString !== prev ? baseValueString : prev - }) + /** + * Sync local content with the base value when not streaming, adjusted during render (no effect) + * so the wand's currentValue never lags a commit. localContent is shown only while streaming. + */ + if (!wandHook.isStreaming) { + const baseValueString = baseValue?.toString() ?? '' + if (baseValueString !== localContent) { + setLocalContent(baseValueString) } - }, [baseValue, wandHook.isStreaming]) + } // Update height when rows prop changes useLayoutEffect(() => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/mcp-dynamic-args/mcp-dynamic-args.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/mcp-dynamic-args/mcp-dynamic-args.tsx index 5fa807b3f73..66134878c77 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/mcp-dynamic-args/mcp-dynamic-args.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/mcp-dynamic-args/mcp-dynamic-args.tsx @@ -45,6 +45,27 @@ function createParamConfig( } } +/** + * Determines the input control type to render for an MCP tool parameter schema. + */ +function getInputType(paramSchema: any) { + if (paramSchema.enum) return 'dropdown' + if (paramSchema.type === 'boolean') return 'switch' + if (paramSchema.type === 'number' || paramSchema.type === 'integer') { + if (paramSchema.minimum !== undefined && paramSchema.maximum !== undefined) { + return 'slider' + } + return 'short-input' + } + if (paramSchema.type === 'string') { + if (paramSchema.format === 'date-time') return 'short-input' + if (paramSchema.maxLength && paramSchema.maxLength > 100) return 'long-input' + return 'short-input' + } + if (paramSchema.type === 'array') return 'long-input' + return 'short-input' +} + export function McpDynamicArgs({ blockId, subBlockId, @@ -115,24 +136,6 @@ export function McpDynamicArgs({ [currentArgs, setToolArgs, disabled] ) - const getInputType = (paramSchema: any) => { - if (paramSchema.enum) return 'dropdown' - if (paramSchema.type === 'boolean') return 'switch' - if (paramSchema.type === 'number' || paramSchema.type === 'integer') { - if (paramSchema.minimum !== undefined && paramSchema.maximum !== undefined) { - return 'slider' - } - return 'short-input' - } - if (paramSchema.type === 'string') { - if (paramSchema.format === 'date-time') return 'short-input' - if (paramSchema.maxLength && paramSchema.maxLength > 100) return 'long-input' - return 'short-input' - } - if (paramSchema.type === 'array') return 'long-input' - return 'short-input' - } - const renderParameterInput = (paramName: string, paramSchema: any) => { const current = currentArgs() const value = current[paramName] @@ -336,6 +339,7 @@ export function McpDynamicArgs({ type='text' name='fakeusernameremembered' autoComplete='username' + aria-label='Hidden username field to prevent autofill' style={{ position: 'absolute', left: '-9999px', opacity: 0, pointerEvents: 'none' }} tabIndex={-1} readOnly @@ -344,6 +348,7 @@ export function McpDynamicArgs({ type='password' name='fakepasswordremembered' autoComplete='current-password' + aria-label='Hidden password field to prevent autofill' style={{ position: 'absolute', left: '-9999px', opacity: 0, pointerEvents: 'none' }} tabIndex={-1} readOnly @@ -352,6 +357,7 @@ export function McpDynamicArgs({ type='email' name='fakeemailremembered' autoComplete='email' + aria-label='Hidden email field to prevent autofill' style={{ position: 'absolute', left: '-9999px', opacity: 0, pointerEvents: 'none' }} tabIndex={-1} readOnly diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/mcp-server-modal/mcp-server-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/mcp-server-modal/mcp-server-selector.tsx index ac65ed2eb68..b8bc8ef91e4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/mcp-server-modal/mcp-server-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/mcp-server-modal/mcp-server-selector.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useMemo, useState } from 'react' +import { useMemo, useRef, useState } from 'react' import { Combobox } from '@sim/emcn' import { useParams } from 'next/navigation' import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text' @@ -28,7 +28,6 @@ export function McpServerSelector({ const activeSearchTarget = useActiveSearchTarget() const params = useParams() const workspaceId = params.workspaceId as string - const [inputValue, setInputValue] = useState('') const { data: servers = [], isLoading, error } = useMcpServers(workspaceId) const enabledServers = servers.filter((s) => s.enabled && !s.deletedAt) @@ -42,6 +41,13 @@ export function McpServerSelector({ const selectedServer = enabledServers.find((server) => server.id === selectedServerId) + const [inputValue, setInputValue] = useState(() => (selectedServer ? selectedServer.name : '')) + const prevSelectedServerRef = useRef(selectedServer) + if (prevSelectedServerRef.current !== selectedServer) { + prevSelectedServerRef.current = selectedServer + setInputValue(selectedServer ? selectedServer.name : '') + } + const comboboxOptions = useMemo( () => enabledServers.map((server) => ({ @@ -63,13 +69,6 @@ export function McpServerSelector({ } } - useEffect(() => { - if (selectedServer) { - setInputValue(selectedServer.name) - } else { - setInputValue('') - } - }, [selectedServer]) const workflowSearchHighlight = getWorkflowSearchLabelHighlight({ activeSearchTarget, subBlockId: subBlock.id, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/mcp-server-modal/mcp-tool-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/mcp-server-modal/mcp-tool-selector.tsx index 10739b60484..14e1a8d8d95 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/mcp-server-modal/mcp-tool-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/mcp-server-modal/mcp-tool-selector.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { Combobox } from '@sim/emcn' import { useParams } from 'next/navigation' import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text' @@ -55,6 +55,12 @@ export function McpToolSelector({ const selectedTool = availableTools.find((tool) => tool.id === selectedToolId) + const prevSelectedToolRef = useRef(undefined) + if (prevSelectedToolRef.current !== selectedTool) { + prevSelectedToolRef.current = selectedTool + setInputValue(selectedTool ? selectedTool.name : '') + } + useEffect(() => { if (serverValue && selectedToolId && !selectedTool && availableTools.length === 0) { refreshTools() @@ -103,14 +109,6 @@ export function McpToolSelector({ } } - useEffect(() => { - if (selectedTool) { - setInputValue(selectedTool.name) - } else { - setInputValue('') - } - }, [selectedTool]) - const isDisabled = disabled || !serverValue const workflowSearchHighlight = getWorkflowSearchLabelHighlight({ activeSearchTarget, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/messages-input/messages-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/messages-input/messages-input.tsx index 6f1367e2141..c7850217484 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/messages-input/messages-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/messages-input/messages-input.tsx @@ -43,6 +43,13 @@ const ROLE_BEFORE_CONTENT_PATTERN = /"role"\s*:\s*"(system|user|assistant)"[^{]* const unescapeContent = (str: string): string => str.replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\') +/** + * Capitalizes the first letter of the role + */ +const formatRole = (role: string): string => { + return role.charAt(0).toUpperCase() + role.slice(1) +} + /** * Interface for individual message in the messages array */ @@ -245,22 +252,38 @@ export function MessagesInput({ [wandHook] ) - const localMessagesRef = useRef(localMessages) - localMessagesRef.current = localMessages + /** + * Adopts an external message source (preview prop or persisted store value) into the + * local editable buffer during render, so the sync happens without an extra committed + * frame or a copy-into-state effect. Only reacts when one of the sources changes + * identity, and only overwrites when the value actually differs (deep), matching the + * previous effect's guards. Regenerates stable render keys in lockstep with the reset. + */ + const messagesSyncRef = useRef<{ + isPreview: boolean + previewValue: typeof previewValue + messages: typeof messages + } | null>(null) - useEffect(() => { + if ( + messagesSyncRef.current === null || + messagesSyncRef.current.isPreview !== isPreview || + messagesSyncRef.current.previewValue !== previewValue || + messagesSyncRef.current.messages !== messages + ) { + messagesSyncRef.current = { isPreview, previewValue, messages } if (isPreview && previewValue && Array.isArray(previewValue)) { - if (!isEqual(localMessagesRef.current, previewValue)) { + if (!isEqual(localMessages, previewValue)) { messageIdsRef.current = previewValue.map(() => generateShortId()) setLocalMessages(previewValue) } } else if (messages && Array.isArray(messages) && messages.length > 0) { - if (!isEqual(localMessagesRef.current, messages)) { + if (!isEqual(localMessages, messages)) { messageIdsRef.current = messages.map(() => generateShortId()) setLocalMessages(messages) } } - }, [isPreview, previewValue, messages]) + } /** * Gets the current messages array @@ -390,13 +413,6 @@ export function MessagesInput({ [localMessages, setMessages, isPreview, disabled] ) - /** - * Capitalizes the first letter of the role - */ - const formatRole = (role: string): string => { - return role.charAt(0).toUpperCase() + role.slice(1) - } - /** * Handles header click to focus the textarea */ @@ -716,6 +732,7 @@ export function MessagesInput({ textareaRefs.current[fieldId] = el }} className='relative z-[2] m-0 box-border h-auto min-h-[80px] w-full resize-none overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-words border-none bg-transparent p-2 font-medium font-sans text-sm text-transparent leading-[1.5] caret-[var(--text-primary)] outline-none [-ms-overflow-style:none] [scrollbar-width:none] placeholder:text-[var(--text-muted)] focus:outline-none focus-visible:outline-none disabled:cursor-not-allowed [&::-webkit-scrollbar]:hidden' + aria-label={`Message ${index + 1} content`} placeholder='Enter message content...' value={message.content} onChange={fieldHandlers.onChange} @@ -797,6 +814,7 @@ export function MessagesInput({
handleResizeStart(fieldId, e)} onDragStart={(e) => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox.tsx index e52684555d9..a748e4fa1b4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox.tsx @@ -1,5 +1,5 @@ import type React from 'react' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useMemo, useRef, useState } from 'react' import { Button, Combobox as EditableCombobox } from '@sim/emcn' import { X } from 'lucide-react' import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text' @@ -82,19 +82,10 @@ export function SelectorCombobox({ 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]) + if (previousActiveValue.current !== activeValue) { + previousActiveValue.current = activeValue + setIsEditing(false) + } const comboboxOptions = useMemo( () => @@ -128,7 +119,7 @@ export function SelectorCombobox({ ) const showClearButton = Boolean(activeValue) && !disabled && !readOnly - const displayValue = allowSearch ? inputValue : selectedLabel + const displayValue = allowSearch ? (isEditing ? inputValue : selectedLabel) : selectedLabel const workflowSearchHighlight = getWorkflowSearchLabelHighlight({ activeSearchTarget, blockId, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/skill-input/skill-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/skill-input/skill-input.tsx index 80be87af9ea..9ee3c1828d3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/skill-input/skill-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/skill-input/skill-input.tsx @@ -147,12 +147,23 @@ export function SkillInput({ className='group relative flex flex-col overflow-hidden rounded-sm border border-[var(--border-1)] transition-all duration-200 ease-in-out' >
{ if (fullSkill && !disabled && !isPreview) { setEditingSkill(fullSkill) } }} + onKeyDown={(e) => { + if (e.target !== e.currentTarget) return + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + if (fullSkill && !disabled && !isPreview) { + setEditingSkill(fullSkill) + } + } + }} >
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slack-setup-wizard/slack-setup-wizard.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slack-setup-wizard/slack-setup-wizard.tsx index 3d3745abb4c..7a850783e8e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slack-setup-wizard/slack-setup-wizard.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slack-setup-wizard/slack-setup-wizard.tsx @@ -570,8 +570,11 @@ function useCapabilitySelection(blockId: string): ReadonlySet { }) }) ) - return useMemo( - () => new Set(SLACK_CAPABILITIES.filter((_, i) => enabledFlags[i]).map((c) => c.id)), - [enabledFlags] - ) + return useMemo(() => { + const selected = new Set() + SLACK_CAPABILITIES.forEach((c, i) => { + if (enabledFlags[i]) selected.add(c.id) + }) + return selected + }, [enabledFlags]) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sort-builder/components/sort-rule-row.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sort-builder/components/sort-rule-row.tsx index 35c68663cde..5ddb241083b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sort-builder/components/sort-rule-row.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sort-builder/components/sort-rule-row.tsx @@ -61,58 +61,6 @@ export function SortRuleRow({ label, }) - const renderHeader = () => ( -
onToggleCollapse(rule.id)} - onKeyDown={(event) => { - if (event.target !== event.currentTarget) return - handleKeyboardActivation(event, () => onToggleCollapse(rule.id)) - }} - > -
- - {rule.collapsed && rule.column - ? formatDisplayText(getColumnLabel(rule.column), { - workflowSearchHighlight: getLabelHighlight('column', getColumnLabel(rule.column)), - }) - : `Sort ${index + 1}`} - - {rule.collapsed && rule.column && ( - - {formatDisplayText(getDirectionLabel(rule.direction), { - workflowSearchHighlight: getLabelHighlight( - 'direction', - getDirectionLabel(rule.direction) - ), - })} - - )} -
-
e.stopPropagation()} - > - - -
-
- ) - const renderContent = () => (
@@ -168,7 +116,55 @@ export function SortRuleRow({ rule.collapsed ? 'overflow-hidden' : 'overflow-visible' )} > - {renderHeader()} +
onToggleCollapse(rule.id)} + onKeyDown={(event) => { + if (event.target !== event.currentTarget) return + handleKeyboardActivation(event, () => onToggleCollapse(rule.id)) + }} + > +
+ + {rule.collapsed && rule.column + ? formatDisplayText(getColumnLabel(rule.column), { + workflowSearchHighlight: getLabelHighlight('column', getColumnLabel(rule.column)), + }) + : `Sort ${index + 1}`} + + {rule.collapsed && rule.column && ( + + {formatDisplayText(getDirectionLabel(rule.direction), { + workflowSearchHighlight: getLabelHighlight( + 'direction', + getDirectionLabel(rule.direction) + ), + })} + + )} +
+
e.stopPropagation()} + > + + +
+
{!rule.collapsed && renderContent()}
) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sort-builder/sort-builder.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sort-builder/sort-builder.tsx index d11ec546900..8efe9635248 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sort-builder/sort-builder.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sort-builder/sort-builder.tsx @@ -52,7 +52,7 @@ export function SortBuilder({ ) const value = isPreview ? previewValue : storeValue - const rules: SortRule[] = Array.isArray(value) ? value : [] + const rules: SortRule[] = useMemo(() => (Array.isArray(value) ? value : []), [value]) const isReadOnly = isPreview || disabled const addRule = useCallback(() => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx index 25324d61f91..f80d2617e20 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx @@ -92,6 +92,35 @@ const validateFieldName = (name: string): string => name.replace(/[\x00-\x1F"\\] const jsonHighlight = (code: string): string => highlight(code, languages.json, 'json') +/** + * Renders a field label. A stable component (not an inline render call) so React + * preserves its identity across renders. + */ +function FieldLabel({ label }: { label: string }) { + return +} + +/** + * Renders the line-number gutter for the code editors. A stable component (not an + * inline render call) so React preserves its identity across renders. + */ +function LineNumbers({ count }: { count: number }) { + return Array.from({ length: count }, (_, i) => ( +
+ {i + 1} +
+ )) +} + +/** + * Generates a unique field key for name inputs to avoid collision with value inputs + */ +const getNameFieldKey = (fieldId: string) => `name-${fieldId}` + export function FieldFormat({ blockId, subBlockId, @@ -140,8 +169,6 @@ export function FieldFormat({ const fields: Field[] = Array.isArray(value) && value.length > 0 ? value : [fallbackField] const isReadOnly = isPreview || disabled - const renderFieldLabel = (label: string) => - /** * Resolves the current editor mode for a file field. The uploader is only * offered when it can represent the stored value losslessly (empty or all @@ -284,11 +311,6 @@ export function FieldFormat({ if (overlay) overlay.scrollLeft = scrollLeft } - /** - * Generates a unique field key for name inputs to avoid collision with value inputs - */ - const getNameFieldKey = (fieldId: string) => `name-${fieldId}` - /** * Renders the name input field with tag dropdown support */ @@ -480,21 +502,11 @@ export function FieldFormat({ const lineCount = fieldValue.split('\n').length const gutterWidth = calculateGutterWidth(lineCount) - const renderLineNumbers = () => { - return Array.from({ length: lineCount }, (_, i) => ( -
- {i + 1} -
- )) - } - return ( - {renderLineNumbers()} + + + {'{\n "key": "value"\n}'} @@ -515,21 +527,11 @@ export function FieldFormat({ const lineCount = fieldValue.split('\n').length const gutterWidth = calculateGutterWidth(lineCount) - const renderLineNumbers = () => { - return Array.from({ length: lineCount }, (_, i) => ( -
- {i + 1} -
- )) - } - return ( - {renderLineNumbers()} + + + {'[\n 1, 2, 3\n]'} @@ -574,20 +576,12 @@ export function FieldFormat({ const lineCount = fieldValue.split('\n').length const gutterWidth = calculateGutterWidth(lineCount) - const renderLineNumbers = () => - Array.from({ length: lineCount }, (_, i) => ( -
- {i + 1} -
- )) return ( - {renderLineNumbers()} + + + { @@ -672,13 +666,13 @@ export function FieldFormat({
- {renderFieldLabel('Name')} +
{renderNameInput(field)}
{showType && (
- {renderFieldLabel('Type')} + - {renderFieldLabel('Description')} +
{isFileFieldType(field.type) ? (
- {renderFieldLabel('Value')} + {renderFileModeToggle(field)}
) : ( - renderFieldLabel('Value') + )}
{renderValueInput(field)}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/table/table.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/table/table.tsx index 80996a1adae..74227cb554a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/table/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/table/table.tsx @@ -50,6 +50,72 @@ interface TableCellProps { subBlockId: string } +interface TableHeaderProps { + columns: string[] + blockId: string + subBlockId: string + activeSearchTarget: ReturnType +} + +function TableHeader({ columns, blockId, subBlockId, activeSearchTarget }: TableHeaderProps) { + return ( + + + {columns.map((column, index) => { + const workflowSearchHighlight = getWorkflowSearchLabelHighlight({ + activeSearchTarget, + blockId, + subBlockId, + valuePath: ['columns', index], + label: column, + }) + return ( + + {formatDisplayText(column, { workflowSearchHighlight })} + + ) + })} + + + ) +} + +interface DeleteButtonCellProps { + rowIndex: number + rowsLength: number + isPreview: boolean + disabled: boolean + onDelete: (rowIndex: number) => void +} + +function DeleteButtonCell({ + rowIndex, + rowsLength, + isPreview, + disabled, + onDelete, +}: DeleteButtonCellProps) { + if (rowsLength <= 1 || isPreview || disabled) return null + + return ( + + + + ) +} + function TableCell({ row, rowIndex, @@ -142,6 +208,7 @@ function TableCell({ type='text' value={cellValue} placeholder={column} + aria-label={column} onChange={handlers.onChange} onKeyDown={handlers.onKeyDown} onScroll={handleScroll} @@ -326,53 +393,16 @@ export function Table({ setStoreValue(rows.filter((_, index) => index !== rowIndex)) } - const renderHeader = () => ( - - - {columns.map((column, index) => { - const workflowSearchHighlight = getWorkflowSearchLabelHighlight({ - activeSearchTarget, - blockId, - subBlockId, - valuePath: ['columns', index], - label: column, - }) - return ( - - {formatDisplayText(column, { workflowSearchHighlight })} - - ) - })} - - - ) - - const renderDeleteButton = (rowIndex: number) => - rows.length > 1 && - !isPreview && - !disabled && ( - - - - ) - return (
- {renderHeader()} + {rows.map((row, rowIndex) => ( ))} - {renderDeleteButton(rowIndex)} + ))} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/components/keyboard-navigation-handler.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/components/keyboard-navigation-handler.tsx index 73e17424c91..b5505b5a5d1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/components/keyboard-navigation-handler.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/components/keyboard-navigation-handler.tsx @@ -118,6 +118,11 @@ export const KeyboardNavigationHandler: React.FC const indices: number[] = [] const nestedPath = nestedNav?.nestedPath ?? [] + const tagIndexMap = new Map() + flatTagList.forEach((item, i) => { + if (!tagIndexMap.has(item.tag)) tagIndexMap.set(item.tag, i) + }) + if (isInFolder && currentFolder) { let currentNestedTag: NestedTag | null = null @@ -144,7 +149,7 @@ export const KeyboardNavigationHandler: React.FC } if (currentNestedTag.children) { for (const child of currentNestedTag.children) { - const idx = flatTagList.findIndex((item) => item.tag === child.fullTag) + const idx = tagIndexMap.get(child.fullTag) ?? -1 if (idx >= 0) { indices.push(idx) } @@ -153,7 +158,7 @@ export const KeyboardNavigationHandler: React.FC if (currentNestedTag.nestedChildren) { for (const nestedChild of currentNestedTag.nestedChildren) { if (nestedChild.parentTag) { - const idx = flatTagList.findIndex((item) => item.tag === nestedChild.parentTag) + const idx = tagIndexMap.get(nestedChild.parentTag) ?? -1 if (idx >= 0) { indices.push(idx) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx index 6f62851c289..b22aa4016fb 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx @@ -42,6 +42,8 @@ import type { BlockState } from '@/stores/workflows/workflow/types' const EMPTY_VARIABLES: Variable[] = [] +const emptyVariableInfoMap: Record = {} + /** * Context for sharing nested navigation state between components. * This enables unlimited nesting depth with a single back button. @@ -65,7 +67,7 @@ interface NestedNavigationContextValue { const NestedNavigationContext = React.createContext(null) /** Hook to access nested navigation state from child components */ -export const useNestedNavigation = () => React.useContext(NestedNavigationContext) +export const useNestedNavigation = () => React.use(NestedNavigationContext) /** * Props for the TagDropdown component @@ -1019,8 +1021,6 @@ export const TagDropdown: React.FC = ({ [inputValue, cursorPosition] ) - const emptyVariableInfoMap: Record = {} - /** * Computes tags, variable info, and block tag groups */ @@ -1349,7 +1349,6 @@ export const TagDropdown: React.FC = ({ loops, parallels, workflowVariables, - workflowId, ]) const filteredTags = useMemo(() => { @@ -1366,14 +1365,15 @@ export const TagDropdown: React.FC = ({ } }) - const filteredGroups = blockTagGroups - .map((group: BlockTagGroup) => ({ - ...group, - tags: group.tags.filter( - (tag: string) => !searchTerm || tag.toLowerCase().includes(searchTerm) - ), - })) - .filter((group: BlockTagGroup) => group.tags.length > 0) + const filteredGroups: BlockTagGroup[] = [] + for (const group of blockTagGroups) { + const groupTags = group.tags.filter( + (tag: string) => !searchTerm || tag.toLowerCase().includes(searchTerm) + ) + if (groupTags.length > 0) { + filteredGroups.push({ ...group, tags: groupTags }) + } + } return { variableTags: varTags, @@ -1631,16 +1631,20 @@ export const TagDropdown: React.FC = ({ [nestedPath] ) - useEffect(() => { + const prevVisibleRef = useRef(visible) + if (prevVisibleRef.current !== visible) { + prevVisibleRef.current = visible if (!visible) { setNestedPath([]) baseFolderRef.current = null } - }, [visible]) + } - useEffect(() => { + const prevFlatTagCountRef = useRef(flatTagList.length) + if (prevFlatTagCountRef.current !== flatTagList.length) { + prevFlatTagCountRef.current = flatTagList.length setSelectedIndex(0) - }, [flatTagList.length]) + } const onCloseEvent = useEffectEvent(() => onClose?.()) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/code-editor/code-editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/code-editor/code-editor.tsx index a6df56db169..3666716cade 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/code-editor/code-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/code-editor/code-editor.tsx @@ -36,6 +36,35 @@ interface CodeEditorProps { const EMPTY_SCHEMA_PARAMETERS: NonNullable = [] +interface LineNumbersProps { + visualLineHeights: number[] +} + +function LineNumbers({ visualLineHeights }: LineNumbersProps): ReactElement[] { + const numbers: ReactElement[] = [] + let lineNumber = 1 + + visualLineHeights.forEach((height) => { + for (let i = 0; i < height; i++) { + numbers.push( +
0 ? 'invisible' : 'text-[var(--code-line-number)]' + )} + > + {lineNumber} +
+ ) + } + lineNumber++ + }) + + return numbers +} + export function CodeEditor({ value, onChange, @@ -102,31 +131,6 @@ export function CodeEditor({ const lineCount = value.split('\n').length const gutterWidth = calculateGutterWidth(lineCount) - const renderLineNumbers = () => { - const numbers: ReactElement[] = [] - let lineNumber = 1 - - visualLineHeights.forEach((height) => { - for (let i = 0; i < height; i++) { - numbers.push( -
0 ? 'invisible' : 'text-[var(--code-line-number)]' - )} - > - {lineNumber} -
- ) - } - lineNumber++ - }) - - return numbers - } - const customHighlight = (code: string) => { if (!highlightVariables || language !== 'javascript') { return highlight(code, languages[language], language) @@ -197,7 +201,7 @@ export function CodeEditor({ )} - {renderLineNumbers()} + diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal.tsx index ab399baa0de..00e84d102f6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal.tsx @@ -68,6 +68,64 @@ export interface CustomTool { type ToolSection = 'schema' | 'code' +/** + * Validates a custom tool JSON schema string against the expected function shape. + */ +const validateSchema = (schema: string): { isValid: boolean; error: string | null } => { + if (!schema) return { isValid: false, error: null } + + try { + const parsed = JSON.parse(schema) + + if (!parsed.type || parsed.type !== 'function') { + return { isValid: false, error: 'Missing "type": "function"' } + } + if (!parsed.function || !parsed.function.name) { + return { isValid: false, error: 'Missing function.name field' } + } + if (!parsed.function.parameters) { + return { isValid: false, error: 'Missing function.parameters object' } + } + if (!parsed.function.parameters.type) { + return { isValid: false, error: 'Missing parameters.type field' } + } + if (parsed.function.parameters.properties === undefined) { + return { isValid: false, error: 'Missing parameters.properties field' } + } + if ( + typeof parsed.function.parameters.properties !== 'object' || + parsed.function.parameters.properties === null + ) { + return { isValid: false, error: 'parameters.properties must be an object' } + } + + return { isValid: true, error: null } + } catch { + return { isValid: false, error: 'Invalid JSON format' } + } +} + +/** + * Determines whether the schema-parameter autocomplete dropdown should show for + * the current cursor context, along with matching parameters. + */ +const checkSchemaParamTrigger = (text: string, cursorPos: number, parameters: any[]) => { + if (parameters.length === 0) return { show: false, searchTerm: '' } + + const beforeCursor = text.substring(0, cursorPos) + const words = beforeCursor.split(/[\s=();,{}[\]]+/) + const currentWord = words[words.length - 1] || '' + + if (currentWord.length > 0 && /^[a-zA-Z_][\w]*$/.test(currentWord)) { + const matchingParams = parameters.filter((param) => + param.name.toLowerCase().startsWith(currentWord.toLowerCase()) + ) + return { show: matchingParams.length > 0, searchTerm: currentWord, matches: matchingParams } + } + + return { show: false, searchTerm: '' } +} + export function CustomToolModal({ open, onOpenChange, @@ -84,7 +142,7 @@ export function CustomToolModal({ const [schemaError, setSchemaError] = useState(null) const [codeError, setCodeError] = useState(null) const [isEditing, setIsEditing] = useState(false) - const [toolId, setToolId] = useState(undefined) + const toolIdRef = useRef(undefined) const [initialJsonSchema, setInitialJsonSchema] = useState('') const [initialFunctionCode, setInitialFunctionCode] = useState('') const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) @@ -319,7 +377,7 @@ try { setInitialJsonSchema(schemaValue) setInitialFunctionCode(codeValue) setIsEditing(true) - setToolId(initialValues.id) + toolIdRef.current = initialValues.id } catch (error) { logger.error('Error initializing form with initial values:', { error }) setSchemaError('Failed to load tool data. Please try again.') @@ -350,7 +408,7 @@ try { setCodeError(null) setActiveSection('schema') setIsEditing(false) - setToolId(undefined) + toolIdRef.current = undefined setIsSchemaPromptActive(false) setIsCodePromptActive(false) setSchemaPromptInput('') @@ -369,40 +427,6 @@ try { onOpenChange(false) } - const validateSchema = (schema: string): { isValid: boolean; error: string | null } => { - if (!schema) return { isValid: false, error: null } - - try { - const parsed = JSON.parse(schema) - - if (!parsed.type || parsed.type !== 'function') { - return { isValid: false, error: 'Missing "type": "function"' } - } - if (!parsed.function || !parsed.function.name) { - return { isValid: false, error: 'Missing function.name field' } - } - if (!parsed.function.parameters) { - return { isValid: false, error: 'Missing function.parameters object' } - } - if (!parsed.function.parameters.type) { - return { isValid: false, error: 'Missing parameters.type field' } - } - if (parsed.function.parameters.properties === undefined) { - return { isValid: false, error: 'Missing parameters.properties field' } - } - if ( - typeof parsed.function.parameters.properties !== 'object' || - parsed.function.parameters.properties === null - ) { - return { isValid: false, error: 'parameters.properties must be an object' } - } - - return { isValid: true, error: null } - } catch { - return { isValid: false, error: 'Invalid JSON format' } - } - } - const isSchemaValid = useMemo(() => validateSchema(jsonSchema).isValid, [jsonSchema]) const hasChanges = useMemo(() => { @@ -452,7 +476,7 @@ try { const name = schema.function.name const description = schema.function.description || '' - let toolIdToUpdate: string | undefined = toolId + let toolIdToUpdate: string | undefined = toolIdRef.current if (isEditing && !toolIdToUpdate && initialValues?.schema) { const originalName = initialValues.schema.function?.name if (originalName) { @@ -591,23 +615,6 @@ try { } } - const checkSchemaParamTrigger = (text: string, cursorPos: number, parameters: any[]) => { - if (parameters.length === 0) return { show: false, searchTerm: '' } - - const beforeCursor = text.substring(0, cursorPos) - const words = beforeCursor.split(/[\s=();,{}[\]]+/) - const currentWord = words[words.length - 1] || '' - - if (currentWord.length > 0 && /^[a-zA-Z_][\w]*$/.test(currentWord)) { - const matchingParams = parameters.filter((param) => - param.name.toLowerCase().startsWith(currentWord.toLowerCase()) - ) - return { show: matchingParams.length > 0, searchTerm: currentWord, matches: matchingParams } - } - - return { show: false, searchTerm: '' } - } - const handleEnvVarSelect = (newValue: string) => { setFunctionCode(newValue) setShowEnvVars(false) @@ -794,6 +801,7 @@ try { } const handleDelete = async () => { + const toolId = toolIdRef.current if (!toolId || !isEditing) return try { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx index 46e7ff7c15e..41c21f14982 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx @@ -392,6 +392,16 @@ function getOperationOptions(blockType: string): { label: string; id: string }[] }) } +/** + * Evaluates whether a tool parameter's UI condition is satisfied for the given + * stored tool's current values. + */ +function evaluateParameterCondition(param: ToolParameterConfig, tool: StoredTool): boolean { + if (!('uiComponent' in param) || !param.uiComponent?.condition) return true + const currentValues: Record = { operation: tool.operation, ...tool.params } + return evaluateSubBlockCondition(param.uiComponent.condition as SubBlockCondition, currentValues) +} + /** * Creates a styled icon element for tool items in the selection dropdown. * @@ -490,13 +500,16 @@ export const ToolInput = memo(function ToolInput({ const value = isPreview ? previewValue : storeValue - const selectedTools: StoredTool[] = - Array.isArray(value) && - value.length > 0 && - value[0] !== null && - typeof value[0]?.type === 'string' - ? (value as StoredTool[]) - : [] + const selectedTools = useMemo( + () => + Array.isArray(value) && + value.length > 0 && + value[0] !== null && + typeof value[0]?.type === 'string' + ? (value as StoredTool[]) + : [], + [value] + ) // Tool categories the consuming block can't run (declared on its tool-input // subBlock): shown in the picker but greyed out with a tooltip instead of added. @@ -512,8 +525,9 @@ export const ToolInput = memo(function ToolInput({ // Uses canonical resolution so the active field (basic vs advanced) is respected. const toolCredentialId = useMemo(() => { const allBlocks = getAllBlocks() + const blocksByType = new Map(allBlocks.map((b) => [b.type, b])) for (const tool of selectedTools) { - const blockConfig = allBlocks.find((b: { type: string }) => b.type === tool.type) + const blockConfig = blocksByType.get(tool.type) if (!blockConfig?.subBlocks) continue const toolCanonical = buildCanonicalIndex(blockConfig.subBlocks) const scopedOverrides = scopeCanonicalModesForTool(canonicalModeOverrides, tool.type) @@ -734,15 +748,18 @@ export const ToolInput = memo(function ToolInput({ * @param blockType - The block type for the tool * @returns `true` if tool is already selected (for single-operation tools only) */ - const isToolAlreadySelected = (toolId: string, blockType: string) => { - if (hasMultipleOperations(blockType)) { - return false - } - if (blockType === 'workflow' || blockType === 'knowledge') { - return false - } - return selectedTools.some((tool) => tool.toolId === toolId) - } + const isToolAlreadySelected = useCallback( + (toolId: string, blockType: string) => { + if (hasMultipleOperations(blockType)) { + return false + } + if (blockType === 'workflow' || blockType === 'knowledge') { + return false + } + return selectedTools.some((tool) => tool.toolId === toolId) + }, + [selectedTools] + ) /** * Groups MCP tools by their parent server. @@ -1017,7 +1034,7 @@ export const ToolInput = memo(function ToolInput({ ) ) }, - [isPreview, disabled, selectedTools, getToolIdForOperation, blockId, setStoreValue] + [isPreview, disabled, selectedTools, blockId, setStoreValue] ) const handleUsageControlChange = useCallback( @@ -1116,15 +1133,6 @@ export const ToolInput = memo(function ToolInput({ setDragOverIndex(null) } - const evaluateParameterCondition = (param: ToolParameterConfig, tool: StoredTool): boolean => { - if (!('uiComponent' in param) || !param.uiComponent?.condition) return true - const currentValues: Record = { operation: tool.operation, ...tool.params } - return evaluateSubBlockCondition( - param.uiComponent.condition as SubBlockCondition, - currentValues - ) - } - const getParamActiveSearchTarget = ( toolIndex: number | undefined, paramId: string, @@ -1202,12 +1210,13 @@ export const ToolInput = memo(function ToolInput({ switch (uiComponent.type) { case 'dropdown': { const options = - (uiComponent.options as { id?: string; label: string; value?: string }[] | undefined) - ?.filter((option) => (option.id ?? option.value) !== '') - .map((option) => ({ - label: option.label, - value: option.id ?? option.value ?? '', - })) || [] + ( + uiComponent.options as { id?: string; label: string; value?: string }[] | undefined + )?.flatMap((option) => + (option.id ?? option.value) !== '' + ? [{ label: option.label, value: option.id ?? option.value ?? '' }] + : [] + ) || [] const selectedLabel = options.find((option) => option.value === value)?.label ?? '' const workflowSearchHighlight = getWorkflowSearchLabelHighlight({ activeSearchTarget: paramActiveSearchTarget, @@ -1362,11 +1371,12 @@ export const ToolInput = memo(function ToolInput({ const server = mcpServers.find((s) => s.id === mcpServerDrilldown) const serverName = tools[0]?.serverName || server?.name || 'Unknown Server' const toolCount = tools.length - const selectedToolIdsForServer = new Set( - selectedTools - .filter((t) => t.type === 'mcp' && t.params?.serverId === mcpServerDrilldown) - .map((t) => t.toolId) - ) + const selectedToolIdsForServer = new Set() + for (const t of selectedTools) { + if (t.type === 'mcp' && t.params?.serverId === mcpServerDrilldown) { + selectedToolIdsForServer.add(t.toolId) + } + } const allAlreadySelected = tools.every((t) => selectedToolIdsForServer.has(t.id)) const serverToolItems: ComboboxOption[] = [] @@ -1525,9 +1535,10 @@ export const ToolInput = memo(function ToolInput({ // MCP Servers — root folder view if (!permissionConfig.disableMcpTools && !mcpUnsupported && mcpToolsByServer.size > 0) { const serverItems: ComboboxOption[] = [] + const serversById = new Map(mcpServers.map((s) => [s.id, s])) for (const [serverId, tools] of mcpToolsByServer) { - const server = mcpServers.find((s) => s.id === serverId) + const server = serversById.get(serverId) const serverName = tools[0]?.serverName || server?.name || 'Unknown Server' const toolCount = tools.length @@ -1624,7 +1635,6 @@ export const ToolInput = memo(function ToolInput({ }, [ mcpServerDrilldown, customTools, - availableMcpTools, mcpServers, mcpToolsByServer, toolBlocks, @@ -1901,6 +1911,7 @@ export const ToolInput = memo(function ToolInput({ > - ) : ( - setSearchQuery(e.target.value)} - onBlur={handleSearchBlur} - className='w-full border-none bg-transparent pr-0.5 text-right font-medium text-[var(--text-primary)] text-small placeholder:text-[var(--text-muted)] focus:outline-none' - /> - )} - - - - {/* Single scroll container with three collapsible sections */} -
- - - +

Toolbar

+
+ {!isSearchActive ? ( + + ) : ( + setSearchQuery(e.target.value)} + onBlur={handleSearchBlur} + className='w-full border-none bg-transparent pr-0.5 text-right font-medium text-[var(--text-primary)] text-small placeholder:text-[var(--text-muted)] focus:outline-none' + /> + )}
+
- {/* Toolbar Item Context Menu */} - + + + - ) - }) -) + + {/* Toolbar Item Context Menu */} + + + ) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index 8233f1a4bc4..a14594a9823 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -200,9 +200,9 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel /** * Opens subscription settings modal */ - const openSubscriptionSettings = () => { + const openSubscriptionSettings = useCallback(() => { navigateToSettings({ section: 'billing' }) - } + }, [navigateToSettings]) /** * Cancels the currently executing workflow @@ -220,7 +220,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel return } await handleRunWorkflow() - }, [usageExceeded, handleRunWorkflow]) + }, [usageExceeded, handleRunWorkflow, openSubscriptionSettings]) // Chat state const { isChatOpen, setIsChatOpen } = useChatStore( @@ -265,7 +265,10 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel // Auto-select most recent on first list arrival per workflow, and drop a // selection that no longer matches anything in the current list (e.g. the // chat was deleted in another tab). - const autoSelectAttemptedForRef = useRef>(new Set()) + const autoSelectAttemptedForRef = useRef | null>(null) + if (autoSelectAttemptedForRef.current === null) { + autoSelectAttemptedForRef.current = new Set() + } useEffect(() => { if (!activeWorkflowId) return @@ -275,9 +278,11 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel } if (copilotChatId) return - if (autoSelectAttemptedForRef.current.has(activeWorkflowId)) return + const autoSelectAttemptedFor = autoSelectAttemptedForRef.current ?? new Set() + autoSelectAttemptedForRef.current = autoSelectAttemptedFor + if (autoSelectAttemptedFor.has(activeWorkflowId)) return if (copilotChatList.length === 0) return - autoSelectAttemptedForRef.current.add(activeWorkflowId) + autoSelectAttemptedFor.add(activeWorkflowId) setCopilotChatId(copilotChatList[0].id) }, [copilotChatList, copilotChatId, activeWorkflowId, setCopilotChatId]) @@ -554,7 +559,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel setIsExporting(false) setIsMenuOpen(false) } - }, [currentWorkflow, activeWorkflowId, downloadFile]) + }, [currentWorkflow, activeWorkflowId, workspaceId, downloadFile]) /** * Handles duplicating the current workflow diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node.tsx index efdb4153e8f..2e03a48aeae 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node.tsx @@ -50,12 +50,13 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps { + const nodesById = new Map(getNodes().map((n) => [n.id, n])) let level = 0 let currentParentId = data?.parentId while (currentParentId) { level++ - const parentNode = getNodes().find((n) => n.id === currentParentId) + const parentNode = nodesById.get(currentParentId) if (!parentNode) break currentParentId = parentNode.data?.parentId } @@ -63,6 +64,11 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps , + [id, data.kind, canEditWorkflow] + ) + return ( setCurrentBlockId(id)} - actionBar={} + actionBar={actionBar} /> ) }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/structured-output.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/structured-output.tsx index e92ca7f0f3a..be4b1d4f30d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/structured-output.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/structured-output.tsx @@ -1,16 +1,7 @@ 'use client' import type React from 'react' -import { - createContext, - memo, - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useState, -} from 'react' +import { createContext, memo, use, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Badge, ChevronDown, cn } from '@sim/emcn' import { useVirtualizer } from '@tanstack/react-virtual' import { isUserFileDisplayMetadata } from '@/lib/core/utils/user-file' @@ -269,16 +260,24 @@ function buildPathToIndicesMap(matchPaths: string[]): Map { return map } +interface HighlightedSegmentsProps { + text: string + query: string + matchIndices: number[] + currentMatchIndex: number + path: string +} + /** * Renders text with search highlights using segments. */ -function renderHighlightedSegments( - text: string, - query: string, - matchIndices: number[], - currentMatchIndex: number, - path: string -): React.ReactNode { +function HighlightedSegments({ + text, + query, + matchIndices, + currentMatchIndex, + path, +}: HighlightedSegmentsProps): React.ReactNode { if (!query || matchIndices.length === 0) return text const textMatches = findTextMatches(text, query) @@ -335,13 +334,19 @@ const HighlightedText = memo(function HighlightedText({ path, currentMatchIndex, }: HighlightedTextProps) { - const searchContext = useContext(SearchContext) + const searchContext = use(SearchContext) if (!searchContext || matchIndices.length === 0) return <>{text} return ( <> - {renderHighlightedSegments(text, searchContext.query, matchIndices, currentMatchIndex, path)} + ) }) @@ -371,7 +376,7 @@ const StructuredNode = memo(function StructuredNode({ currentMatchIndex, isError = false, }: StructuredNodeProps) { - const searchContext = useContext(SearchContext) + const searchContext = use(SearchContext) const displayValue = getDisplayValue(value) const type = getTypeLabel(displayValue) const isPrimitiveValue = isPrimitive(displayValue) @@ -704,13 +709,13 @@ function VirtualizedRow({ wrapText ? '[word-break:break-word]' : 'whitespace-nowrap' )} > - {renderHighlightedSegments( - row.displayText, - searchQuery, - row.matchIndices, - currentMatchIndex, - row.path - )} + ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/output-panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/output-panel.tsx index 7256230d994..3ddd80a0d1c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/output-panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/output-panel.tsx @@ -270,11 +270,7 @@ export const OutputPanel = React.memo(function OutputPanel({ return () => document.removeEventListener('selectionchange', handleSelectionChange) }, [isOutputMenuOpen]) - // Memoize the search query for structured output to avoid re-renders - const structuredSearchQuery = useMemo( - () => (isOutputSearchActive ? outputSearchQuery : undefined), - [isOutputSearchActive, outputSearchQuery] - ) + const structuredSearchQuery = isOutputSearchActive ? outputSearchQuery : undefined const outputDataStringified = useMemo(() => { if ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx index c96aac2f30a..e27e157e52f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx @@ -1,7 +1,7 @@ 'use client' import type React from 'react' -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { memo, useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from 'react' import { Button, ChevronDown, @@ -159,7 +159,6 @@ const IterationNodeRow = memo(function IterationNodeRow({ selectedEntryId, onSelectEntry, isExpanded, - onToggle, expandedNodes, onToggleNode, renderChildren = true, @@ -168,12 +167,12 @@ const IterationNodeRow = memo(function IterationNodeRow({ selectedEntryId: string | null onSelectEntry: (entry: ConsoleEntry) => void isExpanded: boolean - onToggle: () => void expandedNodes: Set onToggleNode: (nodeId: string) => void renderChildren?: boolean }) { const { entry, children, iterationInfo } = node + const handleToggle = useCallback(() => onToggleNode(entry.id), [onToggleNode, entry.id]) const hasError = Boolean(entry.error) || children.some((c) => c.entry.error) const hasChildren = children.length > 0 const hasRunningChild = children.some((c) => c.entry.isRunning) @@ -192,9 +191,11 @@ const IterationNodeRow = memo(function IterationNodeRow({ className={clsx(ROW_STYLES.base, 'h-[30px]', ROW_STYLES.hover)} onClick={(e) => { e.stopPropagation() - onToggle() + handleToggle() }} - onKeyDown={(event) => handleKeyboardActivation(event, onToggle, { stopPropagation: true })} + onKeyDown={(event) => + handleKeyboardActivation(event, handleToggle, { stopPropagation: true }) + } >
onToggleNode(iterNode.entry.id)} expandedNodes={expandedNodes} onToggleNode={onToggleNode} /> @@ -537,7 +537,6 @@ const EntryNodeRow = memo(function EntryNodeRow({ selectedEntryId={selectedEntryId} onSelectEntry={onSelectEntry} isExpanded={expandedNodes.has(node.entry.id)} - onToggle={() => onToggleNode(node.entry.id)} expandedNodes={expandedNodes} onToggleNode={onToggleNode} renderChildren={renderChildren} @@ -1172,6 +1171,14 @@ export const Terminal = memo(function Terminal() { [focusTerminal] ) + /** + * Effect Events for the keyboard handler so the window listener never + * re-subscribes when these callbacks change identity, while still seeing + * the latest props and state. + */ + const expandToLastHeightEvent = useEffectEvent(expandToLastHeight) + const navigateToEntryEvent = useEffectEvent(navigateToEntry) + /** * Consolidated keyboard handler for all terminal navigation */ @@ -1211,21 +1218,21 @@ export const Terminal = memo(function Terminal() { // If no entry selected, select the first or last based on direction if (!currentEntry) { const targetEntry = e.key === 'ArrowDown' ? entries[0] : entries[entries.length - 1] - navigateToEntry(targetEntry) + navigateToEntryEvent(targetEntry) return } const currentIndex = entries.findIndex((navEntry) => navEntry.entry.id === currentEntry.id) if (currentIndex === -1) { // Current entry not in navigable list (shouldn't happen), select first - navigateToEntry(entries[0]) + navigateToEntryEvent(entries[0]) return } if (e.key === 'ArrowUp' && currentIndex > 0) { - navigateToEntry(entries[currentIndex - 1]) + navigateToEntryEvent(entries[currentIndex - 1]) } else if (e.key === 'ArrowDown' && currentIndex < entries.length - 1) { - navigateToEntry(entries[currentIndex + 1]) + navigateToEntryEvent(entries[currentIndex + 1]) } return } @@ -1237,7 +1244,7 @@ export const Terminal = memo(function Terminal() { e.preventDefault() if (!isExpandedRef.current) { - expandToLastHeight() + expandToLastHeightEvent() } if (e.key === 'ArrowLeft' && showInputRef.current) { @@ -1250,7 +1257,7 @@ export const Terminal = memo(function Terminal() { window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) - }, [expandToLastHeight, navigateToEntry]) + }, []) /** * Adjust output panel width on resize. diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/variables/variables.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/variables/variables.tsx index eff41e09f82..22b26481274 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/variables/variables.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/variables/variables.tsx @@ -60,6 +60,13 @@ const HEADER_ICON_SIZE = 16 const LINE_HEIGHT = 21 const MIN_EDITOR_HEIGHT = 120 +/** Blurs the variable-name input when Enter is pressed to commit the edit. */ +function handleVariableNameKeyDown(e: React.KeyboardEvent) { + if (e.key === 'Enter') { + e.currentTarget.blur() + } +} + /** * User-facing strings for errors, labels, and placeholders */ @@ -418,12 +425,6 @@ export function Variables({ readOnly = false }: VariablesProps) { [localNames, isDuplicateName, collaborativeUpdateVariable, readOnly] ) - const handleVariableNameKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - e.currentTarget.blur() - } - } - const handleClose = () => { setIsOpen(false) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/wand-prompt-bar/wand-prompt-bar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/wand-prompt-bar/wand-prompt-bar.tsx index 89848f74c12..267cf5013e6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/wand-prompt-bar/wand-prompt-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/wand-prompt-bar/wand-prompt-bar.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { cn } from '@sim/emcn' import { SendIcon, XIcon } from 'lucide-react' import { Button } from '@/components/ui/button' @@ -28,14 +28,14 @@ export function WandPromptBar({ }: WandPromptBarProps) { const promptBarRef = useRef(null) const [isExiting, setIsExiting] = useState(false) - const [prevIsVisible, setPrevIsVisible] = useState(isVisible) - if (isVisible !== prevIsVisible) { - setPrevIsVisible(isVisible) + const prevIsVisibleRef = useRef(isVisible) + if (prevIsVisibleRef.current !== isVisible) { + prevIsVisibleRef.current = isVisible if (isVisible) setIsExiting(false) } // Handle the fade-out animation - const handleCancel = () => { + const handleCancel = useCallback(() => { if (!isLoading && !isStreaming) { setIsExiting(true) // Wait for animation to complete before actual cancellation @@ -44,7 +44,7 @@ export function WandPromptBar({ onCancel() }, 150) // Matches the CSS transition duration } - } + }, [isLoading, isStreaming, onCancel]) useEffect(() => { // Handle click outside @@ -68,7 +68,7 @@ export function WandPromptBar({ return () => { document.removeEventListener('mousedown', handleClickOutside) } - }, [isVisible, isStreaming, isLoading, isExiting, onCancel]) + }, [isVisible, isStreaming, isLoading, isExiting, handleCancel]) if (!isVisible && !isStreaming && !isExiting) { return null @@ -94,6 +94,7 @@ export function WandPromptBar({ value={isStreaming ? 'Generating...' : promptValue} onChange={(e) => !isStreaming && onChange(e.target.value)} placeholder={placeholder} + aria-label={placeholder} autoComplete='off' autoCorrect='off' autoCapitalize='off' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/hooks/use-webhook-info.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/hooks/use-webhook-info.ts index 332fea24821..b2843021d5e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/hooks/use-webhook-info.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/hooks/use-webhook-info.ts @@ -72,17 +72,16 @@ export function useWebhookInfo(blockId: string, workflowId: string): UseWebhookI const isDisabled = isWebhookConfigured && webhook?.isActive === false const webhookId = isWebhookConfigured ? webhook?.id : undefined - const reactivateMutation = useReactivateWebhook() + const { mutateAsync: reactivateWebhookMutation } = useReactivateWebhook() const reactivateWebhook = useCallback( async (id: string) => { try { - await reactivateMutation.mutateAsync({ webhookId: id, workflowId, blockId }) + await reactivateWebhookMutation({ webhookId: id, workflowId, blockId }) } catch (error) { logger.error('Error reactivating webhook:', error) } }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [workflowId, blockId] + [reactivateWebhookMutation, workflowId, blockId] ) return { 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 6c7976327e5..b397f854b74 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 @@ -68,6 +68,11 @@ const logger = createLogger('WorkflowBlock') /** Stable empty object to avoid creating new references */ const EMPTY_SUBBLOCK_VALUES = {} as Record +/** Whether connecting `source` to `target` would introduce a cycle in the current graph. */ +function wouldCreateConnectionCycle(source: string, target: string) { + return wouldCreateCycle(useWorkflowStore.getState().edges, source, target) +} + interface SubBlockRowProps { title: string value?: string @@ -166,13 +171,7 @@ const SubBlockRow = memo(function SubBlockRow({ } return accumulator }, {}) - }, [ - canonicalIndex, - canonicalModeOverrides, - displayAdvancedOptions, - rawValues, - subBlock?.dependsOn, - ]) + }, [canonicalIndex, canonicalModeOverrides, rawValues, subBlock?.dependsOn]) const credentialSourceId = subBlock?.type === 'oauth-input' && typeof rawValue === 'string' ? rawValue : undefined @@ -587,9 +586,11 @@ export const WorkflowBlock = memo(function WorkflowBlock({ const subBlockRows = subBlockRowsData.rows const subBlockState = subBlockRowsData.stateToUse - const topologySubBlocks = data.isPreview - ? (data.blockState?.subBlocks ?? {}) - : (currentStoreBlock?.subBlocks ?? {}) + const topologySubBlocks = useMemo( + () => + data.isPreview ? (data.blockState?.subBlocks ?? {}) : (currentStoreBlock?.subBlocks ?? {}), + [data.isPreview, data.blockState?.subBlocks, currentStoreBlock?.subBlocks] + ) const effectiveAdvanced = useMemo(() => { const rawValues = Object.entries(subBlockState).reduce>( (acc, [key, entry]) => { @@ -700,57 +701,75 @@ export const WorkflowBlock = memo(function WorkflowBlock({ type === 'schedule' && !isLoadingScheduleInfo && scheduleInfo !== null const isWorkflowSelector = type === 'workflow' || type === 'workflow_input' - const wouldCreateConnectionCycle = (source: string, target: string) => - wouldCreateCycle(useWorkflowStore.getState().edges, source, target) - const webhookProviderName = webhookProvider ? getProviderName(webhookProvider) : undefined - const rows = - type === 'condition' || type === 'router_v2' ? null : ( - <> - {subBlockRows.map((row, rowIndex) => - row.flatMap((subBlock) => { - const rawValue = subBlockState[subBlock.id]?.value - if (subBlock.type === 'mcp-dynamic-args') { - const schema = subBlockState._toolSchema?.value as - | { properties?: Record } - | undefined - const properties = schema?.properties - if (properties && typeof properties === 'object') { - const args = (rawValue && typeof rawValue === 'object' ? rawValue : {}) as Record< - string, - unknown - > - return Object.keys(properties).map((paramName) => ( - - )) + const rows = useMemo( + () => + type === 'condition' || type === 'router_v2' ? null : ( + <> + {subBlockRows.map((row, rowIndex) => + row.flatMap((subBlock) => { + const rawValue = subBlockState[subBlock.id]?.value + if (subBlock.type === 'mcp-dynamic-args') { + const schema = subBlockState._toolSchema?.value as + | { properties?: Record } + | undefined + const properties = schema?.properties + if (properties && typeof properties === 'object') { + const args = (rawValue && typeof rawValue === 'object' ? rawValue : {}) as Record< + string, + unknown + > + return Object.keys(properties).map((paramName) => ( + + )) + } + return [] } - return [] - } - return [ - , - ] - }) - )} - - ) + return [ + , + ] + }) + )} + + ), + [ + type, + subBlockRows, + subBlockState, + workspaceId, + currentWorkflowId, + id, + effectiveAdvanced, + canonicalIndex, + canonicalModeOverrides, + ] + ) + + const actionBar = useMemo( + () => + !data.isPreview && !data.isEmbedded ? ( + + ) : undefined, + [data.isPreview, data.isEmbedded, id, type, canEditWorkflow] + ) return ( - ) : undefined - } + actionBar={actionBar} rows={rows} /> ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/float/use-float-resize.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/float/use-float-resize.ts index c33b5987fc0..6eb1a4d69a7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/float/use-float-resize.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/float/use-float-resize.ts @@ -323,7 +323,7 @@ export function useFloatResize({ y: finalY, }) }, - [onDimensionsChange, onPositionChange] + [onDimensionsChange, onPositionChange, minWidth, maxWidth, minHeight, maxHeight] ) const handleGlobalMouseMoveRef = useRef(handleGlobalMouseMove) handleGlobalMouseMoveRef.current = handleGlobalMouseMove diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-dimensions.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-dimensions.ts index aaa20254231..83869368221 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-dimensions.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-dimensions.ts @@ -30,9 +30,11 @@ export function useBlockDimensions({ const updateNodeInternals = useUpdateNodeInternals() const updateBlockLayoutMetrics = useWorkflowStore((state) => state.updateBlockLayoutMetrics) const previousDimensions = useRef(null) + const calculateDimensionsRef = useRef(calculateDimensions) + calculateDimensionsRef.current = calculateDimensions useEffect(() => { - const dimensions = calculateDimensions() + const dimensions = calculateDimensionsRef.current() const previous = previousDimensions.current if (!previous || previous.width !== dimensions.width || previous.height !== dimensions.height) { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-dynamic-handle-refresh.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-dynamic-handle-refresh.ts index aa6c9c7e333..f1be93070e9 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-dynamic-handle-refresh.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-dynamic-handle-refresh.ts @@ -9,13 +9,16 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store' export function useDynamicHandleRefresh() { const updateNodeInternals = useUpdateNodeInternals() const blocks = useWorkflowStore((state) => state.blocks) - const previousSignaturesRef = useRef>(new Map()) + const previousSignaturesRef = useRef | null>(null) + if (previousSignaturesRef.current === null) { + previousSignaturesRef.current = new Map() + } const signatures = useMemo(() => collectDynamicHandleTopologySignatures(blocks), [blocks]) useEffect(() => { const changedBlockIds = getChangedDynamicHandleBlockIds( - previousSignaturesRef.current, + previousSignaturesRef.current ?? new Map(), signatures ) previousSignaturesRef.current = signatures diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts index 77d5cde2635..6b1f2a993bc 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts @@ -224,40 +224,47 @@ export function useNodeUtilities(blocks: Record) { loopPosition: { x: number; y: number } dimensions: { width: number; height: number } } | null => { - const containingNodes = getNodes() - .filter((n) => n.type && isContainerType(n.type)) - .filter((n) => { - // Use absolute coordinates for nested containers - const absolutePos = getNodeAbsolutePosition(n.id) - const rect = { - left: absolutePos.x, - right: absolutePos.x + (n.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH), - top: absolutePos.y, - bottom: absolutePos.y + (n.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT), - } - - return ( - position.x >= rect.left && - position.x <= rect.right && - position.y >= rect.top && - position.y <= rect.bottom - ) - }) - .map((n) => ({ - loopId: n.id, - loopPosition: getNodeAbsolutePosition(n.id), - dimensions: { - width: n.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH, - height: n.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, - }, - })) + const containingNodes: Array<{ + loopId: string + loopPosition: { x: number; y: number } + dimensions: { width: number; height: number } + }> = [] + + for (const n of getNodes()) { + if (!n.type || !isContainerType(n.type)) continue + + // Use absolute coordinates for nested containers + const absolutePos = getNodeAbsolutePosition(n.id) + const rect = { + left: absolutePos.x, + right: absolutePos.x + (n.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH), + top: absolutePos.y, + bottom: absolutePos.y + (n.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT), + } + + if ( + position.x >= rect.left && + position.x <= rect.right && + position.y >= rect.top && + position.y <= rect.bottom + ) { + containingNodes.push({ + loopId: n.id, + loopPosition: getNodeAbsolutePosition(n.id), + dimensions: { + width: n.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH, + height: n.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, + }, + }) + } + } if (containingNodes.length > 0) { - return containingNodes.sort((a, b) => { - const aArea = a.dimensions.width * a.dimensions.height - const bArea = b.dimensions.width * b.dimensions.height - return aArea - bArea - })[0] + return containingNodes.reduce((smallest, node) => { + const nodeArea = node.dimensions.width * node.dimensions.height + const smallestArea = smallest.dimensions.width * smallest.dimensions.height + return nodeArea < smallestArea ? node : smallest + }) } return null @@ -298,14 +305,16 @@ export function useNodeUtilities(blocks: Record) { const resizeLoopNodes = useCallback( (updateNodeDimensions: (id: string, dimensions: { width: number; height: number }) => void) => { const currentBlocks = useWorkflowStore.getState().blocks - const containerBlocks = Object.entries(currentBlocks) - .filter(([, block]) => block?.type && isContainerType(block.type)) - .map(([id, block]) => ({ - id, - block, - depth: getNodeDepth(id), - })) - .sort((a, b) => b.depth - a.depth) + const containerBlocks: Array<{ + id: string + block: (typeof currentBlocks)[string] + depth: number + }> = [] + for (const [id, block] of Object.entries(currentBlocks)) { + if (!block?.type || !isContainerType(block.type)) continue + containerBlocks.push({ id, block, depth: getNodeDepth(id) }) + } + containerBlocks.sort((a, b) => b.depth - a.depth) for (const { id, block } of containerBlocks) { const dimensions = calculateLoopDimensions(id) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand.ts index 1ac9e042573..669150a3f9f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand.ts @@ -108,6 +108,7 @@ export function useWand({ const [isStreaming, setIsStreaming] = useState(false) const [conversationHistory, setConversationHistory] = useState([]) + const conversationHistoryRef = useRef([]) const abortControllerRef = useRef(null) @@ -192,7 +193,7 @@ export function useWand({ prompt: userMessage, systemPrompt: systemPrompt, stream: true, - history: wandConfig?.maintainHistory ? conversationHistory : [], + history: wandConfig?.maintainHistory ? conversationHistoryRef.current : [], generationType: wandConfig?.generationType, workflowId: workflowId ?? undefined, workspaceId: workspaceId ?? undefined, @@ -221,11 +222,13 @@ export function useWand({ onGeneratedContent(accumulatedContent) if (wandConfig?.maintainHistory) { - setConversationHistory((prev) => [ - ...prev, + const nextHistory: ChatMessage[] = [ + ...conversationHistoryRef.current, { role: 'user', content: currentPrompt }, { role: 'assistant', content: accumulatedContent }, - ]) + ] + conversationHistoryRef.current = nextHistory + setConversationHistory(nextHistory) } if (onGenerationComplete) { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts index 4346acc4650..e062740cbe8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts @@ -354,6 +354,60 @@ export function useWorkflowExecution() { ) }, []) + const persistLogs = useCallback( + async (executionId: string, result: ExecutionResult, streamContent?: string) => { + try { + // Build trace spans from execution logs + const { traceSpans, totalDuration } = buildTraceSpans(result) + + // Add trace spans to the execution result + const enrichedResult = { + ...result, + traceSpans, + totalDuration, + } + + // If this was a streaming response and we have the final content, update it + if (streamContent && result.output && typeof streamContent === 'string') { + // Update the content with the final streaming content + enrichedResult.output.content = streamContent + + // Also update any block logs to include the content where appropriate + if (enrichedResult.logs) { + // Get the streaming block ID from metadata if available + const streamingBlockId = (result.metadata as any)?.streamingBlockId || null + + for (const log of enrichedResult.logs) { + // Only update the specific LLM block (agent/router) that was streamed + const isStreamingBlock = streamingBlockId && log.blockId === streamingBlockId + if ( + isStreamingBlock && + (log.blockType === 'agent' || log.blockType === 'router') && + log.output + ) + log.output.content = streamContent + } + } + } + + if (!activeWorkflowId) return executionId + await requestJson(workflowLogContract, { + params: { id: activeWorkflowId }, + body: { + executionId, + result: enrichedResult, + }, + }) + + return executionId + } catch (error) { + logger.error('Error persisting logs:', error) + return executionId + } + }, + [activeWorkflowId] + ) + /** * Handles debug session completion */ @@ -368,7 +422,7 @@ export function useWorkflowExecution() { // Reset debug state resetDebugState() }, - [activeWorkflowId, resetDebugState] + [persistLogs, resetDebugState] ) /** @@ -415,64 +469,9 @@ export function useWorkflowExecution() { // Reset debug state resetDebugState() }, - [debugContext, activeWorkflowId, resetDebugState] + [debugContext, persistLogs, resetDebugState] ) - const persistLogs = async ( - executionId: string, - result: ExecutionResult, - streamContent?: string - ) => { - try { - // Build trace spans from execution logs - const { traceSpans, totalDuration } = buildTraceSpans(result) - - // Add trace spans to the execution result - const enrichedResult = { - ...result, - traceSpans, - totalDuration, - } - - // If this was a streaming response and we have the final content, update it - if (streamContent && result.output && typeof streamContent === 'string') { - // Update the content with the final streaming content - enrichedResult.output.content = streamContent - - // Also update any block logs to include the content where appropriate - if (enrichedResult.logs) { - // Get the streaming block ID from metadata if available - const streamingBlockId = (result.metadata as any)?.streamingBlockId || null - - for (const log of enrichedResult.logs) { - // Only update the specific LLM block (agent/router) that was streamed - const isStreamingBlock = streamingBlockId && log.blockId === streamingBlockId - if ( - isStreamingBlock && - (log.blockType === 'agent' || log.blockType === 'router') && - log.output - ) - log.output.content = streamContent - } - } - } - - if (!activeWorkflowId) return executionId - await requestJson(workflowLogContract, { - params: { id: activeWorkflowId }, - body: { - executionId, - result: enrichedResult, - }, - }) - - return executionId - } catch (error) { - logger.error('Error persisting logs:', error) - return executionId - } - } - const handleRunWorkflow = useCallback( async (workflowInput?: any, enableDebug = false) => { if (!activeWorkflowId) return @@ -1586,7 +1585,6 @@ export function useWorkflowExecution() { executor, debugContext, pendingBlocks, - activeWorkflowId, validateDebugState, resetDebugState, isDebugSessionComplete, @@ -1638,10 +1636,12 @@ export function useWorkflowExecution() { currentResult = await executor!.continueExecution(currentPendingBlocks, currentContext) + const resultPendingBlocks = currentResult.metadata?.pendingBlocks + logger.info('Resume iteration result:', { success: currentResult.success, - hasPendingBlocks: !!currentResult.metadata?.pendingBlocks, - pendingBlockCount: currentResult.metadata?.pendingBlocks?.length || 0, + hasPendingBlocks: !!resultPendingBlocks, + pendingBlockCount: resultPendingBlocks?.length || 0, }) // Update context for next iteration @@ -1653,8 +1653,8 @@ export function useWorkflowExecution() { } // Update pending blocks for next iteration - if (currentResult.metadata?.pendingBlocks) { - currentPendingBlocks = currentResult.metadata.pendingBlocks + if (resultPendingBlocks) { + currentPendingBlocks = resultPendingBlocks } else { logger.info('No pending blocks in result, ending resume') break @@ -1687,7 +1687,6 @@ export function useWorkflowExecution() { executor, debugContext, pendingBlocks, - activeWorkflowId, validateDebugState, resetDebugState, handleDebugSessionComplete, @@ -2059,8 +2058,6 @@ export function useWorkflowExecution() { setCurrentExecutionId, setIsExecuting, setActiveBlocks, - setBlockRunStatus, - setEdgeRunStatus, updateConsole, finishRunningEntries, setExecutionResult, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts index 2fa772f78bb..72b4f6cdd95 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts @@ -226,7 +226,10 @@ export function resolveParentChildSelectionConflicts( nodes: Node[], blocks: Record ): Node[] { - const selectedIds = new Set(nodes.filter((n) => n.selected).map((n) => n.id)) + const selectedIds = new Set() + for (const n of nodes) { + if (n.selected) selectedIds.add(n.id) + } let hasConflict = false const resolved = nodes.map((n) => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 1e87115e592..8f4a91302eb 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -553,7 +553,14 @@ const WorkflowContent = React.memo( if (!isShowingDiff && isDiffReady && diffAnalysis?.edge_diff?.deleted_edges) { const reconstructedEdges: Edge[] = [] - const validHandles = ['source', 'target', 'success', 'error', 'default', 'condition'] + const validHandles = new Set([ + 'source', + 'target', + 'success', + 'error', + 'default', + 'condition', + ]) diffAnalysis.edge_diff.deleted_edges.forEach((edgeIdentifier) => { const parts = edgeIdentifier.split('-') @@ -562,7 +569,7 @@ const WorkflowContent = React.memo( let targetStartIndex = -1 for (let i = 1; i < parts.length - 1; i++) { - if (validHandles.includes(parts[i])) { + if (validHandles.has(parts[i])) { sourceEndIndex = i for (let j = i + 1; j < parts.length - 1; j++) { if (parts[j].length > 0) { @@ -1809,9 +1816,16 @@ const WorkflowContent = React.memo( } ) - const existingChildBlocks = Object.values(blocks) - .filter((b) => b.data?.parentId === containerInfo.loopId) - .map((b) => ({ id: b.id, type: b.type, position: b.position })) + const existingChildBlocks: { + id: string + type: string + position: { x: number; y: number } + }[] = [] + for (const b of Object.values(blocks)) { + if (b.data?.parentId === containerInfo.loopId) { + existingChildBlocks.push({ id: b.id, type: b.type, position: b.position }) + } + } const autoConnectEdge = tryCreateAutoConnectEdge(relativePosition, id, { targetParentId: containerInfo.loopId, @@ -1901,9 +1915,16 @@ const WorkflowContent = React.memo( ) // Capture existing child blocks for auto-connect - const existingChildBlocks = Object.values(blocks) - .filter((b) => b.data?.parentId === containerInfo.loopId) - .map((b) => ({ id: b.id, type: b.type, position: b.position })) + const existingChildBlocks: { + id: string + type: string + position: { x: number; y: number } + }[] = [] + for (const b of Object.values(blocks)) { + if (b.data?.parentId === containerInfo.loopId) { + existingChildBlocks.push({ id: b.id, type: b.type, position: b.position }) + } + } const autoConnectEdge = tryCreateAutoConnectEdge(relativePosition, id, { targetParentId: containerInfo.loopId, @@ -2116,13 +2137,15 @@ const WorkflowContent = React.memo( /** Tracks blocks to pan to after diff updates. */ const pendingZoomBlockIdsRef = useRef | null>(null) - const seenDiffBlocksRef = useRef>(new Set()) + const seenDiffBlocksRef = useRef | null>(null) /** Queues newly changed blocks for viewport panning. */ useEffect(() => { + if (seenDiffBlocksRef.current === null) seenDiffBlocksRef.current = new Set() + const seenDiffBlocks = seenDiffBlocksRef.current if (!isDiffReady || !diffAnalysis) { pendingZoomBlockIdsRef.current = null - seenDiffBlocksRef.current.clear() + seenDiffBlocks.clear() return } @@ -2130,10 +2153,10 @@ const WorkflowContent = React.memo( const allBlocks = [...(diffAnalysis.new_blocks || []), ...(diffAnalysis.edited_blocks || [])] for (const id of allBlocks) { - if (!seenDiffBlocksRef.current.has(id)) { + if (!seenDiffBlocks.has(id)) { newBlocks.add(id) } - seenDiffBlocksRef.current.add(id) + seenDiffBlocks.add(id) } if (newBlocks.size > 0) { @@ -2336,6 +2359,7 @@ const WorkflowContent = React.memo( }) } }, [ + sandbox, workflowIdParam, isWorkflowMapLoading, isWorkflowMapPlaceholderData, @@ -2383,9 +2407,10 @@ const WorkflowContent = React.memo( ) // Validate that workflows belong to the current workspace before redirecting - const workspaceWorkflows = Object.entries(workflows) - .filter(([, workflow]) => workflow.workspaceId === workspaceId) - .map(([id]) => id) + const workspaceWorkflows: string[] = [] + for (const [id, workflow] of Object.entries(workflows)) { + if (workflow.workspaceId === workspaceId) workspaceWorkflows.push(id) + } if (workspaceWorkflows.length > 0) { router.replace(`/workspace/${workspaceId}/w/${workspaceWorkflows[0]}`) @@ -2407,6 +2432,7 @@ const WorkflowContent = React.memo( } }, [ embedded, + sandbox, workflowIdParam, isWorkflowMapLoading, isWorkflowMapPlaceholderData, @@ -2419,12 +2445,14 @@ const WorkflowContent = React.memo( workflows, ]) - const blockConfigCache = useRef>(new Map()) + const blockConfigCache = useRef | null>(null) const getBlockConfig = useCallback((type: string) => { - if (!blockConfigCache.current.has(type)) { - blockConfigCache.current.set(type, getBlock(type)) + if (blockConfigCache.current === null) blockConfigCache.current = new Map() + const cache = blockConfigCache.current + if (!cache.has(type)) { + cache.set(type, getBlock(type)) } - return blockConfigCache.current.get(type) + return cache.get(type) }, []) const prevBlocksHashRef = useRef('') @@ -2566,7 +2594,6 @@ const WorkflowContent = React.memo( return nodeArray }, [ - blocksStructureHash, blocks, activeBlockIds, pendingBlocks, @@ -2581,23 +2608,29 @@ const WorkflowContent = React.memo( const [displayNodes, setDisplayNodes] = useState([]) const [lastInteractedNodeId, setLastInteractedNodeId] = useState(null) - const selectedNodeIds = useMemo( - () => displayNodes.filter((node) => node.selected).map((node) => node.id), - [displayNodes] - ) + const selectedNodeIds = useMemo(() => { + const ids: string[] = [] + for (const node of displayNodes) { + if (node.selected) ids.push(node.id) + } + return ids + }, [displayNodes]) const selectedNodeIdsKey = selectedNodeIds.join(',') useEffect(() => { syncPanelWithSelection(selectedNodeIds) }, [selectedNodeIdsKey]) - // Keep the most recently selected block on top even after deselection, so a - // dragged block doesn't suddenly drop behind other overlapping blocks. - useEffect(() => { - if (selectedNodeIds.length > 0) { - setLastInteractedNodeId(selectedNodeIds[selectedNodeIds.length - 1]) - } - }, [selectedNodeIdsKey]) + /** + * Keep the most recently selected block on top even after deselection (so a dragged block + * doesn't drop behind overlapping blocks). Latched during render — retains the previous id + * when the selection clears, so it's a latch rather than plain derived state. + */ + const lastSelectedNodeId = + selectedNodeIds.length > 0 ? selectedNodeIds[selectedNodeIds.length - 1] : null + if (lastSelectedNodeId !== null && lastSelectedNodeId !== lastInteractedNodeId) { + setLastInteractedNodeId(lastSelectedNodeId) + } useEffect(() => { // Check for pending selection (from paste/duplicate), otherwise preserve existing selection @@ -2617,7 +2650,10 @@ const WorkflowContent = React.memo( // Preserve existing selection state setDisplayNodes((currentNodes) => { - const selectedIds = new Set(currentNodes.filter((n) => n.selected).map((n) => n.id)) + const selectedIds = new Set() + for (const n of currentNodes) { + if (n.selected) selectedIds.add(n.id) + } return derivedNodes.map((node) => ({ ...node, selected: selectedIds.has(node.id), @@ -2979,7 +3015,7 @@ const WorkflowContent = React.memo( const findNodeAtScreenPosition = useCallback( (clientX: number, clientY: number) => { const elements = document.elementsFromPoint(clientX, clientY) - const nodes = getNodes() + const nodesById = new Map(getNodes().map((n) => [n.id, n])) for (const el of elements) { const nodeEl = el.closest('.react-flow__node') as HTMLElement | null @@ -2988,7 +3024,7 @@ const WorkflowContent = React.memo( const nodeId = nodeEl.getAttribute('data-id') if (!nodeId) continue - const node = nodes.find((n) => n.id === nodeId) + const node = nodesById.get(nodeId) if (node && node.type !== 'subflowNode') return node } @@ -3477,9 +3513,16 @@ const WorkflowContent = React.memo( } // Auto-connect when moving an existing block into a container - const existingChildBlocks = Object.values(blocks) - .filter((b) => b.data?.parentId === potentialParentId && b.id !== node.id) - .map((b) => ({ id: b.id, type: b.type, position: b.position })) + const existingChildBlocks: { + id: string + type: string + position: { x: number; y: number } + }[] = [] + for (const b of Object.values(blocks)) { + if (b.data?.parentId === potentialParentId && b.id !== node.id) { + existingChildBlocks.push({ id: b.id, type: b.type, position: b.position }) + } + } const autoConnectEdge = tryCreateAutoConnectEdge(relativePositionBefore, node.id, { targetParentId: potentialParentId, @@ -3717,7 +3760,6 @@ const WorkflowContent = React.memo( potentialParentId, getNodeAbsolutePosition, getNodeDepth, - clearDragHighlights, highlightContainerNode, ] ) @@ -3748,6 +3790,7 @@ const WorkflowContent = React.memo( potentialParentId, clearDragHighlights, executeBatchParentUpdate, + setDragStartPosition, ] ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx index 1c095a9c4c1..0482a123339 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx @@ -194,17 +194,10 @@ function CollapsibleSection({ return (
-
setIsExpanded(!isExpanded)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - setIsExpanded(!isExpanded) - } - }} - role='button' - tabIndex={0} aria-expanded={isExpanded} aria-label={`${isExpanded ? 'Collapse' : 'Expand'} ${title.toLowerCase()}`} > @@ -224,7 +217,7 @@ function CollapsibleSection({ isExpanded && 'rotate-180' )} /> -
+ {isExpanded && ( <> @@ -287,9 +280,9 @@ function ConnectionsSection({ const [expandedVariables, setExpandedVariables] = useState(true) const [expandedEnvVars, setExpandedEnvVars] = useState(true) - const [prevConnectionIds, setPrevConnectionIds] = useState(connectionIds) - if (connectionIds !== prevConnectionIds) { - setPrevConnectionIds(connectionIds) + const prevConnectionIdsRef = useRef(connectionIds) + if (connectionIds !== prevConnectionIdsRef.current) { + prevConnectionIdsRef.current = connectionIds setExpandedBlocks(new Set(connectionIds.split(',').filter(Boolean))) } @@ -773,7 +766,7 @@ function PreviewEditorContent({ }, [workflowVariables]) const blockConfig = getBlock(block.type) as BlockConfig | undefined - const subBlockValues = block.subBlocks || {} + const subBlockValues = useMemo(() => block.subBlocks || {}, [block.subBlocks]) const params = useParams() const workspaceId = params.workspaceId as string diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx index c28f99bbc8b..ab2eba5cb70 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx @@ -53,6 +53,9 @@ const ERROR_HANDLE_STYLE: CSSProperties = { transform: 'translateY(50%)', } +/** Stable empty workflow map so memoized subblock rows keep a constant reference. */ +const EMPTY_WORKFLOW_MAP: Record = {} + interface WorkflowPreviewBlockData { type: string name: string @@ -175,7 +178,7 @@ function WorkflowPreviewBlockInner({ data }: NodeProps const { type, name, - workflowMap = {}, + workflowMap = EMPTY_WORKFLOW_MAP, workflowLabelsReady = false, isTrigger = false, horizontalHandles = false, diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.tsx index ee0790ad673..2263f35a80e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.tsx @@ -355,14 +355,6 @@ export function PreviewWorkflow({ } }, [workflowState.blocks, getSubflowExecutionStatus, blockExecutionMap]) - const edgesStructure = useMemo(() => { - if (!isValidWorkflowState) return { count: 0, ids: '' } - return { - count: workflowState.edges?.length || 0, - ids: workflowState.edges?.map((e) => e.id).join(',') || '', - } - }, [workflowState.edges, isValidWorkflowState]) - const nodes: Node[] = useMemo(() => { if (!isValidWorkflowState) return [] @@ -472,6 +464,7 @@ export function PreviewWorkflow({ workflowState.blocks, isValidWorkflowState, executedBlocks, + blockExecutionMap, selectedBlockId, getSubflowExecutionStatus, workflowMap, @@ -545,13 +538,7 @@ export function PreviewWorkflow({ zIndex: status === 'success' ? 10 : isErrorEdge ? 5 : 0, } }) - }, [ - edgesStructure, - workflowState.edges, - isValidWorkflowState, - blockExecutionMap, - getBlockExecutionStatus, - ]) + }, [workflowState.edges, isValidWorkflowState, blockExecutionMap, getBlockExecutionStatus]) if (!isValidWorkflowState) { return ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/preview.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/preview.tsx index f4755c59241..23d6cfb6303 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/preview.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/preview.tsx @@ -216,9 +216,9 @@ export function Preview({ }) const [workflowStack, setWorkflowStack] = useState([]) - const [prevRootState, setPrevRootState] = useState(rootWorkflowState) - if (rootWorkflowState !== prevRootState) { - setPrevRootState(rootWorkflowState) + const prevRootStateRef = useRef(rootWorkflowState) + if (rootWorkflowState !== prevRootStateRef.current) { + prevRootStateRef.current = rootWorkflowState setWorkflowStack([]) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/help-modal/help-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/help-modal/help-modal.tsx index 41fe122cd38..710c51b6b94 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/help-modal/help-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/help-modal/help-modal.tsx @@ -22,6 +22,7 @@ const logger = createLogger('HelpModal') const MAX_FILE_SIZE = 20 * 1024 * 1024 const TARGET_SIZE_MB = 2 const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif'] +const ACCEPTED_IMAGE_TYPES_SET = new Set(ACCEPTED_IMAGE_TYPES) const SCROLL_DELAY_MS = 100 const SUCCESS_RESET_DELAY_MS = 2000 @@ -213,7 +214,7 @@ export function HelpModal({ open, onOpenChange, workflowId, workspaceId }: HelpM continue } - if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) { + if (!ACCEPTED_IMAGE_TYPES_SET.has(file.type)) { hasError = true continue } @@ -254,7 +255,12 @@ export function HelpModal({ open, onOpenChange, workflowId, workspaceId }: HelpM onOpenChange(false)}>Help & support
-