From 304cf717a4e1a087116d0a6ce0eb6ffe94303c6f Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 28 Jan 2026 10:38:09 -0800 Subject: [PATCH 01/20] improvement(cmdk): refactor search modal to use cmdk + fix icon SVG IDs (#3044) * improvement(cmdk): refactor search modal to use cmdk + fix icon SVG IDs * chore: remove unrelated workflow.tsx changes * chore: remove comments * chore: add devtools middleware to search modal store * fix: allow search data re-initialization when permissions change * fix: include keywords in search filter + show service name in tool operations * fix: correct filterBlocks type signature * fix: move generic to function parameter position * fix(mcp): correct event handler type for onInput * perf: always render command palette for instant opening * fix: clear search input when modal reopens --- apps/docs/components/icons.tsx | 385 ++++--- .../components/search-modal/search-modal.tsx | 994 +++++++----------- .../components/search-modal/search-utils.ts | 241 ----- .../settings-modal/components/mcp/mcp.tsx | 2 +- .../w/components/sidebar/sidebar.tsx | 7 +- apps/sim/components/icons.tsx | 385 ++++--- apps/sim/stores/modals/search/store.ts | 168 ++- apps/sim/stores/modals/search/types.ts | 65 +- 8 files changed, 1042 insertions(+), 1205 deletions(-) delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-utils.ts diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index 9c901860a4..f9b47f43c6 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -1,4 +1,5 @@ import type { SVGProps } from 'react' +import { useId } from 'react' export function SearchIcon(props: SVGProps) { return ( @@ -737,6 +738,9 @@ export function GmailIcon(props: SVGProps) { } export function GrafanaIcon(props: SVGProps) { + const id = useId() + const gradientId = `grafana_gradient_${id}` + return ( ) { fill='none' > ) { } export function SupabaseIcon(props: SVGProps) { + const id = useId() + const gradient0 = `supabase_paint0_${id}` + const gradient1 = `supabase_paint1_${id}` + return ( ) { > ) { /> ) { ) { } export function MistralIcon(props: SVGProps) { + const id = useId() + const clipId = `mistral_clip_${id}` + return ( ) { xmlns='http://www.w3.org/2000/svg' preserveAspectRatio='xMidYMid meet' > - + ) { /> - + @@ -2126,6 +2137,9 @@ export function MicrosoftIcon(props: SVGProps) { } export function MicrosoftTeamsIcon(props: SVGProps) { + const id = useId() + const gradientId = `msteams_gradient_${id}` + return ( ) { d='M1140.333,561.355v103.148c-104.963-24.857-191.679-98.469-233.25-198.003 h138.395C1097.783,466.699,1140.134,509.051,1140.333,561.355z' /> ) { ) { } export function OutlookIcon(props: SVGProps) { + const id = useId() + const gradient1 = `outlook_gradient1_${id}` + const gradient2 = `outlook_gradient2_${id}` + return ( ) { ) { ) { d='M936.833,461.305v823.136c-0.046,43.067-34.861,78.015-77.927,78.225H425.833 V383.25h433.072c43.062,0.023,77.951,34.951,77.927,78.013C936.833,461.277,936.833,461.291,936.833,461.305z' /> ) { ) { } export function MicrosoftExcelIcon(props: SVGProps) { + const id = useId() + const gradientId = `excel_gradient_${id}` + return ( ) { d='M1073.893,479.25H532.5V1704h541.393c53.834-0.175,97.432-43.773,97.607-97.607 V576.857C1171.325,523.023,1127.727,479.425,1073.893,479.25z' /> ) { ) => ( ) -export const AzureIcon = (props: SVGProps) => ( - - - - - - - - - - - - - - - - - - - - - - - -) +export function AzureIcon(props: SVGProps) { + const id = useId() + const gradient0 = `azure_paint0_${id}` + const gradient1 = `azure_paint1_${id}` + const gradient2 = `azure_paint2_${id}` + + return ( + + + + + + + + + + + + + + + + + + + + + + + + ) +} export const GroqIcon = (props: SVGProps) => ( ) => ( ) -export const GeminiIcon = (props: SVGProps) => ( - - Gemini - - - - - - - - - -) +export function GeminiIcon(props: SVGProps) { + const id = useId() + const gradientId = `gemini_gradient_${id}` + + return ( + + Gemini + + + + + + + + + + ) +} export const VertexIcon = (props: SVGProps) => ( ) { } export function QdrantIcon(props: SVGProps) { + const id = useId() + const gradientId = `qdrant_gradient_${id}` + const clipPathId = `qdrant_clippath_${id}` + return ( - + ) { /> ) { - + @@ -3254,28 +3291,33 @@ export const SOC2BadgeIcon = (props: SVGProps) => ( ) -export const HIPAABadgeIcon = (props: SVGProps) => ( - - - - - - - - - - - - -) +export function HIPAABadgeIcon(props: SVGProps) { + const id = useId() + const clipId = `hipaa_clip_${id}` + + return ( + + + + + + + + + + + + + ) +} export function GoogleFormsIcon(props: SVGProps) { return ( @@ -3292,19 +3334,6 @@ export function GoogleFormsIcon(props: SVGProps) { d='M19.229 50.292h16.271v-2.959H19.229v2.959Zm0-17.75v2.958h16.271v-2.958H19.229Zm-3.698 1.479c0 1.224-0.995 2.219-2.219 2.219s-2.219-0.995-2.219-2.219c0-1.224 0.995-2.219 2.219-2.219s2.219 0.995 2.219 2.219Zm0 7.396c0 1.224-0.995 2.219-2.219 2.219s-2.219-0.995-2.219-2.219c0-1.224 0.995-2.219 2.219-2.219s2.219 0.995 2.219 2.219Zm0 7.396c0 1.224-0.995 2.219-2.219 2.219s-2.219-0.995-2.219-2.219c0-1.224 0.995-2.219 2.219-2.219s2.219 0.995 2.219 2.219Zm3.698-5.917h16.271v-2.959H19.229v2.959Z' fill='#F1F1F1' /> - - - - - - ) } @@ -3753,6 +3782,9 @@ export function SentryIcon(props: SVGProps) { } export function IncidentioIcon(props: SVGProps) { + const id = useId() + const clipId = `incidentio_clip_${id}` + return ( ) { fill='none' xmlns='http://www.w3.org/2000/svg' > - + ) { /> - + @@ -3995,6 +4027,9 @@ export function SftpIcon(props: SVGProps) { } export function ApifyIcon(props: SVGProps) { + const id = useId() + const clipId = `apify_clip_${id}` + return ( ) { fill='none' xmlns='http://www.w3.org/2000/svg' > - + ) { /> - + @@ -4128,6 +4163,9 @@ export function TextractIcon(props: SVGProps) { } export function McpIcon(props: SVGProps) { + const id = useId() + const clipId = `mcp_clip_${id}` + return ( ) { fill='none' xmlns='http://www.w3.org/2000/svg' > - + ) { /> - + @@ -4478,6 +4516,10 @@ export function GrainIcon(props: SVGProps) { } export function CirclebackIcon(props: SVGProps) { + const id = useId() + const patternId = `circleback_pattern_${id}` + const imageId = `circleback_image_${id}` + return ( ) { xmlns='http://www.w3.org/2000/svg' xmlnsXlink='http://www.w3.org/1999/xlink' > - + - - + + ) { } export function FirefliesIcon(props: SVGProps) { + const id = useId() + const g1 = `fireflies_g1_${id}` + const g2 = `fireflies_g2_${id}` + const g3 = `fireflies_g3_${id}` + const g4 = `fireflies_g4_${id}` + const g5 = `fireflies_g5_${id}` + const g6 = `fireflies_g6_${id}` + const g7 = `fireflies_g7_${id}` + const g8 = `fireflies_g8_${id}` + return ( ) { ) { ) { ) { ) { ) { ) { ) { - - + + - - + + @@ -4695,10 +4747,13 @@ export function FirefliesIcon(props: SVGProps) { } export function BedrockIcon(props: SVGProps) { + const id = useId() + const gradientId = `bedrock_gradient_${id}` + return ( - + @@ -4706,7 +4761,7 @@ export function BedrockIcon(props: SVGProps) { diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx index d18a40348d..732b4056e7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx @@ -1,19 +1,20 @@ 'use client' -import { memo, useCallback, useEffect, useMemo, useState } from 'react' -import * as DialogPrimitive from '@radix-ui/react-dialog' -import * as VisuallyHidden from '@radix-ui/react-visually-hidden' -import { BookOpen, Layout, RepeatIcon, ScrollText, Search, SplitIcon } from 'lucide-react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Command } from 'cmdk' +import { BookOpen, Layout, ScrollText } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' -import { Dialog, DialogPortal, DialogTitle } from '@/components/ui/dialog' +import { createPortal } from 'react-dom' import { useBrandConfig } from '@/lib/branding/branding' import { cn } from '@/lib/core/utils/cn' -import { getToolOperationsIndex } from '@/lib/search/tool-operations' -import { getTriggersForSidebar, hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils' -import { searchItems } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-utils' +import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils' import { SIDEBAR_SCROLL_EVENT } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar' -import { getAllBlocks } from '@/blocks' -import { usePermissionConfig } from '@/hooks/use-permission-config' +import { useSearchModalStore } from '@/stores/modals/search/store' +import type { + SearchBlockItem, + SearchDocItem, + SearchToolOperationItem, +} from '@/stores/modals/search/types' interface SearchModalProps { open: boolean @@ -38,270 +39,36 @@ interface WorkspaceItem { isCurrent?: boolean } -interface BlockItem { - id: string - name: string - description: string - icon: React.ComponentType - bgColor: string - type: string - config?: any -} - -interface ToolItem { - id: string - name: string - description: string - icon: React.ComponentType - bgColor: string - type: string -} - interface PageItem { id: string name: string - icon: React.ComponentType - href: string - shortcut?: string -} - -interface DocItem { - id: string - name: string - icon: React.ComponentType + icon: React.ComponentType<{ className?: string }> href: string - type: 'main' | 'block' | 'tool' -} - -type SearchItem = { - id: string - name: string - description?: string - icon?: React.ComponentType - bgColor?: string - color?: string - href?: string shortcut?: string - type: 'block' | 'trigger' | 'tool' | 'tool-operation' | 'workflow' | 'workspace' | 'page' | 'doc' - isCurrent?: boolean - blockType?: string - config?: any - operationId?: string - aliases?: string[] } -interface SearchResultItemProps { - item: SearchItem - visualIndex: number - isSelected: boolean - onItemClick: (item: SearchItem) => void -} - -const SearchResultItem = memo(function SearchResultItem({ - item, - visualIndex, - isSelected, - onItemClick, -}: SearchResultItemProps) { - const Icon = item.icon - const showColoredIcon = - item.type === 'block' || - item.type === 'trigger' || - item.type === 'tool' || - item.type === 'tool-operation' - const isWorkflow = item.type === 'workflow' - const isWorkspace = item.type === 'workspace' - - const handleClick = useCallback(() => { - onItemClick(item) - }, [onItemClick, item]) - - return ( -
- ) : ( - Icon && ( -
- -
- ) - )} - - )} - - {/* Content */} - - {item.name} - {item.isCurrent && ' (current)'} - - - {/* Shortcut */} - {item.shortcut && ( - - {item.shortcut} - - )} - - ) -}) - -export const SearchModal = memo(function SearchModal({ +export function SearchModal({ open, onOpenChange, workflows = [], workspaces = [], isOnWorkflowPage = false, }: SearchModalProps) { - const [searchQuery, setSearchQuery] = useState('') - const [selectedIndex, setSelectedIndex] = useState(0) const params = useParams() const router = useRouter() const workspaceId = params.workspaceId as string const brand = useBrandConfig() - const { filterBlocks } = usePermissionConfig() - - const blocks = useMemo(() => { - if (!open || !isOnWorkflowPage) return [] - - const allBlocks = getAllBlocks() - const filteredAllBlocks = filterBlocks(allBlocks) - const regularBlocks = filteredAllBlocks - .filter( - (block) => block.type !== 'starter' && !block.hideFromToolbar && block.category === 'blocks' - ) - .map( - (block): BlockItem => ({ - id: block.type, - name: block.name, - description: block.description || '', - icon: block.icon, - bgColor: block.bgColor || '#6B7280', - type: block.type, - }) - ) + const inputRef = useRef(null) + const [search, setSearch] = useState('') + const [mounted, setMounted] = useState(false) - const specialBlocks: BlockItem[] = [ - { - id: 'loop', - name: 'Loop', - description: 'Create a Loop', - icon: RepeatIcon, - bgColor: '#2FB3FF', - type: 'loop', - }, - { - id: 'parallel', - name: 'Parallel', - description: 'Parallel Execution', - icon: SplitIcon, - bgColor: '#FEE12B', - type: 'parallel', - }, - ] - - return [...regularBlocks, ...filterBlocks(specialBlocks)] - }, [open, isOnWorkflowPage, filterBlocks]) - - const triggers = useMemo(() => { - if (!open || !isOnWorkflowPage) return [] - - const allTriggers = getTriggersForSidebar() - const filteredTriggers = filterBlocks(allTriggers) - const priorityOrder = ['Start', 'Schedule', 'Webhook'] - - const sortedTriggers = filteredTriggers.sort((a, b) => { - const aIndex = priorityOrder.indexOf(a.name) - const bIndex = priorityOrder.indexOf(b.name) - const aHasPriority = aIndex !== -1 - const bHasPriority = bIndex !== -1 - - if (aHasPriority && bHasPriority) return aIndex - bIndex - if (aHasPriority) return -1 - if (bHasPriority) return 1 - return a.name.localeCompare(b.name) - }) + useEffect(() => { + setMounted(true) + }, []) - return sortedTriggers.map( - (block): BlockItem => ({ - id: block.type, - name: block.name, - description: block.description || '', - icon: block.icon, - bgColor: block.bgColor || '#6B7280', - type: block.type, - config: block, - }) - ) - }, [open, isOnWorkflowPage, filterBlocks]) - - const tools = useMemo(() => { - if (!open || !isOnWorkflowPage) return [] - - const allBlocks = getAllBlocks() - const filteredAllBlocks = filterBlocks(allBlocks) - return filteredAllBlocks - .filter((block) => !block.hideFromToolbar && block.category === 'tools') - .map( - (block): ToolItem => ({ - id: block.type, - name: block.name, - description: block.description || '', - icon: block.icon, - bgColor: block.bgColor || '#6B7280', - type: block.type, - }) - ) - }, [open, isOnWorkflowPage, filterBlocks]) - - const toolOperations = useMemo(() => { - if (!open || !isOnWorkflowPage) return [] - - const allowedBlockTypes = new Set(tools.map((t) => t.type)) - - return getToolOperationsIndex() - .filter((op) => allowedBlockTypes.has(op.blockType)) - .map((op) => ({ - id: op.id, - name: `${op.serviceName}: ${op.operationName}`, - icon: op.icon, - bgColor: op.bgColor, - blockType: op.blockType, - operationId: op.operationId, - aliases: op.aliases, - })) - }, [open, isOnWorkflowPage, tools]) + const { blocks, tools, triggers, toolOperations, docs } = useSearchModalStore( + (state) => state.data + ) const pages = useMemo( (): PageItem[] => [ @@ -328,389 +95,384 @@ export const SearchModal = memo(function SearchModal({ [workspaceId, brand.documentationUrl] ) - const docs = useMemo((): DocItem[] => { - if (!open) return [] - - const allBlocks = getAllBlocks() - const docsItems: DocItem[] = [] - - allBlocks.forEach((block) => { - if (block.docsLink && !block.hideFromToolbar) { - docsItems.push({ - id: `docs-${block.type}`, - name: block.name, - icon: block.icon, - href: block.docsLink, - type: block.category === 'blocks' || block.category === 'triggers' ? 'block' : 'tool', - }) - } - }) - - return docsItems - }, [open]) - - const allItems = useMemo((): SearchItem[] => { - const items: SearchItem[] = [] - - workspaces.forEach((workspace) => { - items.push({ - id: workspace.id, - name: workspace.name, - href: workspace.href, - type: 'workspace', - isCurrent: workspace.isCurrent, - }) - }) - - workflows.forEach((workflow) => { - items.push({ - id: workflow.id, - name: workflow.name, - href: workflow.href, - type: 'workflow', - color: workflow.color, - isCurrent: workflow.isCurrent, - }) - }) - - pages.forEach((page) => { - items.push({ - id: page.id, - name: page.name, - icon: page.icon, - href: page.href, - shortcut: page.shortcut, - type: 'page', - }) - }) - - blocks.forEach((block) => { - items.push({ - id: block.id, - name: block.name, - description: block.description, - icon: block.icon, - bgColor: block.bgColor, - type: 'block', - blockType: block.type, - }) - }) - - triggers.forEach((trigger) => { - items.push({ - id: trigger.id, - name: trigger.name, - description: trigger.description, - icon: trigger.icon, - bgColor: trigger.bgColor, - type: 'trigger', - blockType: trigger.type, - config: trigger.config, - }) - }) - - tools.forEach((tool) => { - items.push({ - id: tool.id, - name: tool.name, - description: tool.description, - icon: tool.icon, - bgColor: tool.bgColor, - type: 'tool', - blockType: tool.type, - }) - }) - - toolOperations.forEach((op) => { - items.push({ - id: op.id, - name: op.name, - icon: op.icon, - bgColor: op.bgColor, - type: 'tool-operation', - blockType: op.blockType, - operationId: op.operationId, - aliases: op.aliases, - }) - }) - - docs.forEach((doc) => { - items.push({ - id: doc.id, - name: doc.name, - icon: doc.icon, - href: doc.href, - type: 'doc', - }) - }) - - return items - }, [workspaces, workflows, pages, blocks, triggers, tools, toolOperations, docs]) - - const sectionOrder = useMemo( - () => ['block', 'tool', 'trigger', 'doc', 'tool-operation', 'workflow', 'workspace', 'page'], - [] - ) - - const filteredItems = useMemo(() => { - const orderMap = sectionOrder.reduce>( - (acc, type, index) => { - acc[type] = index - return acc - }, - {} as Record - ) - - if (!searchQuery.trim()) { - return [...allItems].sort((a, b) => { - const aOrder = orderMap[a.type] ?? Number.MAX_SAFE_INTEGER - const bOrder = orderMap[b.type] ?? Number.MAX_SAFE_INTEGER - return aOrder - bOrder - }) - } - - const searchResults = searchItems(searchQuery, allItems) - - return searchResults - .sort((a, b) => { - if (a.score !== b.score) { - return b.score - a.score - } - - const aOrder = orderMap[a.item.type] ?? Number.MAX_SAFE_INTEGER - const bOrder = orderMap[b.item.type] ?? Number.MAX_SAFE_INTEGER - if (aOrder !== bOrder) { - return aOrder - bOrder - } - - return a.item.name.localeCompare(b.item.name) + useEffect(() => { + if (open) { + setSearch('') + requestAnimationFrame(() => { + inputRef.current?.focus() }) - .map((result) => result.item) - }, [allItems, searchQuery, sectionOrder]) - - const groupedItems = useMemo(() => { - const groups: Record = { - workspace: [], - workflow: [], - page: [], - trigger: [], - block: [], - 'tool-operation': [], - tool: [], - doc: [], } + }, [open]) - filteredItems.forEach((item) => { - if (groups[item.type]) { - groups[item.type].push(item) + const handleSearchChange = useCallback((value: string) => { + setSearch(value) + requestAnimationFrame(() => { + const list = document.querySelector('[cmdk-list]') + if (list) { + list.scrollTop = 0 } }) + }, []) - return groups - }, [filteredItems]) + useEffect(() => { + if (!open) return - const displayedItemsInVisualOrder = useMemo(() => { - const visualOrder: SearchItem[] = [] + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault() + onOpenChange(false) + } + } - sectionOrder.forEach((type) => { - const items = groupedItems[type] || [] - items.forEach((item) => { - visualOrder.push(item) - }) - }) + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [open, onOpenChange]) + + const handleBlockSelect = useCallback( + (block: SearchBlockItem, type: 'block' | 'trigger' | 'tool') => { + const enableTriggerMode = + type === 'trigger' && block.config ? hasTriggerCapability(block.config) : false + window.dispatchEvent( + new CustomEvent('add-block-from-toolbar', { + detail: { type: block.type, enableTriggerMode }, + }) + ) + onOpenChange(false) + }, + [onOpenChange] + ) - return visualOrder - }, [groupedItems, sectionOrder]) + const handleToolOperationSelect = useCallback( + (op: SearchToolOperationItem) => { + window.dispatchEvent( + new CustomEvent('add-block-from-toolbar', { + detail: { type: op.blockType, presetOperation: op.operationId }, + }) + ) + onOpenChange(false) + }, + [onOpenChange] + ) - useEffect(() => { - setSelectedIndex(0) - }, [displayedItemsInVisualOrder]) + const handleWorkflowSelect = useCallback( + (workflow: WorkflowItem) => { + if (!workflow.isCurrent && workflow.href) { + router.push(workflow.href) + window.dispatchEvent( + new CustomEvent(SIDEBAR_SCROLL_EVENT, { detail: { itemId: workflow.id } }) + ) + } + onOpenChange(false) + }, + [router, onOpenChange] + ) - useEffect(() => { - if (!open) { - setSearchQuery('') - setSelectedIndex(0) - } - }, [open]) + const handleWorkspaceSelect = useCallback( + (workspace: WorkspaceItem) => { + if (!workspace.isCurrent && workspace.href) { + router.push(workspace.href) + } + onOpenChange(false) + }, + [router, onOpenChange] + ) - const handleItemClick = useCallback( - (item: SearchItem) => { - switch (item.type) { - case 'block': - case 'trigger': - case 'tool': - if (item.blockType) { - const enableTriggerMode = - item.type === 'trigger' && item.config ? hasTriggerCapability(item.config) : false - const event = new CustomEvent('add-block-from-toolbar', { - detail: { - type: item.blockType, - enableTriggerMode, - }, - }) - window.dispatchEvent(event) - } - break - case 'tool-operation': - if (item.blockType && item.operationId) { - const event = new CustomEvent('add-block-from-toolbar', { - detail: { - type: item.blockType, - presetOperation: item.operationId, - }, - }) - window.dispatchEvent(event) - } - break - case 'workspace': - if (item.isCurrent) { - break - } - if (item.href) { - router.push(item.href) - } - break - case 'workflow': - if (!item.isCurrent && item.href) { - router.push(item.href) - window.dispatchEvent( - new CustomEvent(SIDEBAR_SCROLL_EVENT, { detail: { itemId: item.id } }) - ) - } - break - case 'page': - case 'doc': - if (item.href) { - if (item.href.startsWith('http')) { - window.open(item.href, '_blank', 'noopener,noreferrer') - } else { - router.push(item.href) - } - } - break + const handlePageSelect = useCallback( + (page: PageItem) => { + if (page.href.startsWith('http')) { + window.open(page.href, '_blank', 'noopener,noreferrer') + } else { + router.push(page.href) } onOpenChange(false) }, [router, onOpenChange] ) - useEffect(() => { - if (!open) return + const handleDocSelect = useCallback( + (doc: SearchDocItem) => { + window.open(doc.href, '_blank', 'noopener,noreferrer') + onOpenChange(false) + }, + [onOpenChange] + ) - const handleKeyDown = (e: KeyboardEvent) => { - switch (e.key) { - case 'ArrowDown': - e.preventDefault() - setSelectedIndex((prev) => Math.min(prev + 1, displayedItemsInVisualOrder.length - 1)) - break - case 'ArrowUp': - e.preventDefault() - setSelectedIndex((prev) => Math.max(prev - 1, 0)) - break - case 'Enter': - e.preventDefault() - if (displayedItemsInVisualOrder[selectedIndex]) { - handleItemClick(displayedItemsInVisualOrder[selectedIndex]) - } - break - case 'Escape': - e.preventDefault() - onOpenChange(false) - break - } + const showBlocks = isOnWorkflowPage && blocks.length > 0 + const showTools = isOnWorkflowPage && tools.length > 0 + const showTriggers = isOnWorkflowPage && triggers.length > 0 + const showToolOperations = isOnWorkflowPage && toolOperations.length > 0 + const showDocs = isOnWorkflowPage && docs.length > 0 + + const customFilter = useCallback((value: string, search: string, keywords?: string[]) => { + const searchLower = search.toLowerCase() + const valueLower = value.toLowerCase() + + if (valueLower === searchLower) return 1 + if (valueLower.startsWith(searchLower)) return 0.8 + if (valueLower.includes(searchLower)) return 0.6 + + const searchWords = searchLower.split(/\s+/).filter(Boolean) + const allWordsMatch = searchWords.every((word) => valueLower.includes(word)) + if (allWordsMatch && searchWords.length > 0) return 0.4 + + if (keywords?.length) { + const keywordsLower = keywords.join(' ').toLowerCase() + if (keywordsLower.includes(searchLower)) return 0.3 + const keywordWordsMatch = searchWords.every((word) => keywordsLower.includes(word)) + if (keywordWordsMatch && searchWords.length > 0) return 0.2 } - document.addEventListener('keydown', handleKeyDown) - return () => document.removeEventListener('keydown', handleKeyDown) - }, [open, selectedIndex, displayedItemsInVisualOrder, handleItemClick, onOpenChange]) + return 0 + }, []) - useEffect(() => { - if (open && selectedIndex >= 0) { - const element = document.querySelector(`[data-search-item-index="${selectedIndex}"]`) - if (element) { - element.scrollIntoView({ - block: 'nearest', - behavior: 'auto', - }) - } - } - }, [selectedIndex, open]) - - const sectionTitles: Record = { - workspace: 'Workspaces', - workflow: 'Workflows', - page: 'Pages', - trigger: 'Triggers', - block: 'Blocks', - 'tool-operation': 'Tool Operations', - tool: 'Tools', - doc: 'Docs', - } + if (!mounted) return null + + return createPortal( + <> + {/* Overlay */} +
onOpenChange(false)} + aria-hidden={!open} + /> + + {/* Command palette - always rendered for instant opening, hidden with CSS */} +
+ + + + + No results found. + + + {showBlocks && ( + + {blocks.map((block) => ( + handleBlockSelect(block, 'block')} + icon={block.icon} + bgColor={block.bgColor} + showColoredIcon + > + {block.name} + + ))} + + )} + + {showTools && ( + + {tools.map((tool) => ( + handleBlockSelect(tool, 'tool')} + icon={tool.icon} + bgColor={tool.bgColor} + showColoredIcon + > + {tool.name} + + ))} + + )} + + {showTriggers && ( + + {triggers.map((trigger) => ( + handleBlockSelect(trigger, 'trigger')} + icon={trigger.icon} + bgColor={trigger.bgColor} + showColoredIcon + > + {trigger.name} + + ))} + + )} + + {workflows.length > 0 && ( + + {workflows.map((workflow) => ( + handleWorkflowSelect(workflow)} + className='group flex h-[28px] w-full cursor-pointer items-center gap-[8px] rounded-[6px] px-[10px] text-left text-[15px] aria-selected:bg-[var(--border)] aria-selected:shadow-sm data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50' + > +
+ + {workflow.name} + {workflow.isCurrent && ' (current)'} + + + ))} + + )} + + {showToolOperations && ( + + {toolOperations.map((op) => ( + handleToolOperationSelect(op)} + icon={op.icon} + bgColor={op.bgColor} + showColoredIcon + > + {op.name} + + ))} + + )} + + {workspaces.length > 0 && ( + + {workspaces.map((workspace) => ( + handleWorkspaceSelect(workspace)} + className='group flex h-[28px] w-full cursor-pointer items-center gap-[8px] rounded-[6px] px-[10px] text-left text-[15px] aria-selected:bg-[var(--border)] aria-selected:shadow-sm data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50' + > + + {workspace.name} + {workspace.isCurrent && ' (current)'} + + + ))} + + )} + + {showDocs && ( + + {docs.map((doc) => ( + handleDocSelect(doc)} + icon={doc.icon} + bgColor='#6B7280' + showColoredIcon + > + {doc.name} + + ))} + + )} + + {pages.length > 0 && ( + + {pages.map((page) => { + const Icon = page.icon + return ( + handlePageSelect(page)} + className='group flex h-[28px] w-full cursor-pointer items-center gap-[8px] rounded-[6px] px-[10px] text-left text-[15px] aria-selected:bg-[var(--border)] aria-selected:shadow-sm data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50' + > +
+ +
+ + {page.name} + + {page.shortcut && ( + + {page.shortcut} + + )} +
+ ) + })} +
+ )} + + +
+ , + document.body + ) +} + +const groupHeadingClassName = + '[&_[cmdk-group-heading]]:pt-[2px] [&_[cmdk-group-heading]]:pb-[4px] [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-[13px] [&_[cmdk-group-heading]]:text-[var(--text-subtle)] [&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:tracking-wide' + +interface CommandItemProps { + value: string + keywords?: string[] + onSelect: () => void + icon: React.ComponentType<{ className?: string }> + bgColor: string + showColoredIcon?: boolean + children: React.ReactNode +} +function CommandItem({ + value, + keywords, + onSelect, + icon: Icon, + bgColor, + showColoredIcon, + children, +}: CommandItemProps) { return ( - - - - - - Search - - - {/* Search input container */} -
- - setSearchQuery(e.target.value)} - className='w-full border-0 bg-transparent font-base text-[15px] text-[var(--text-primary)] placeholder:text-[var(--text-secondary)] focus:outline-none' - autoFocus - /> -
- - {/* Floating results container */} - {displayedItemsInVisualOrder.length > 0 ? ( -
- {sectionOrder.map((type) => { - const items = groupedItems[type] || [] - if (items.length === 0) return null - - return ( -
- {/* Section header */} -
- {sectionTitles[type]} -
- - {/* Section items */} -
- {items.map((item) => { - const visualIndex = displayedItemsInVisualOrder.indexOf(item) - return ( - - ) - })} -
-
- ) - })} -
- ) : searchQuery ? ( -
-

- No results found for "{searchQuery}" -

-
- ) : null} -
-
-
+ +
+ +
+ + {children} + +
) -}) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-utils.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-utils.ts deleted file mode 100644 index 08525b16f0..0000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-utils.ts +++ /dev/null @@ -1,241 +0,0 @@ -/** - * Search utility functions for tiered matching algorithm - * Provides predictable search results prioritizing exact matches over fuzzy matches - */ - -export interface SearchableItem { - id: string - name: string - description?: string - type: string - aliases?: string[] - [key: string]: any -} - -export interface SearchResult { - item: T - score: number - matchType: 'exact' | 'prefix' | 'alias' | 'word-boundary' | 'substring' | 'description' -} - -const SCORE_EXACT_MATCH = 10000 -const SCORE_PREFIX_MATCH = 5000 -const SCORE_ALIAS_MATCH = 3000 -const SCORE_WORD_BOUNDARY = 1000 -const SCORE_SUBSTRING_MATCH = 100 -const DESCRIPTION_WEIGHT = 0.3 - -/** - * Calculate match score for a single field - * Returns 0 if no match found - */ -function calculateFieldScore( - query: string, - field: string -): { - score: number - matchType: 'exact' | 'prefix' | 'word-boundary' | 'substring' | null -} { - const normalizedQuery = query.toLowerCase().trim() - const normalizedField = field.toLowerCase().trim() - - if (!normalizedQuery || !normalizedField) { - return { score: 0, matchType: null } - } - - // Tier 1: Exact match - if (normalizedField === normalizedQuery) { - return { score: SCORE_EXACT_MATCH, matchType: 'exact' } - } - - // Tier 2: Prefix match (starts with query) - if (normalizedField.startsWith(normalizedQuery)) { - return { score: SCORE_PREFIX_MATCH, matchType: 'prefix' } - } - - // Tier 3: Word boundary match (query matches start of a word) - const words = normalizedField.split(/[\s-_/]+/) - const hasWordBoundaryMatch = words.some((word) => word.startsWith(normalizedQuery)) - if (hasWordBoundaryMatch) { - return { score: SCORE_WORD_BOUNDARY, matchType: 'word-boundary' } - } - - // Tier 4: Substring match (query appears anywhere) - if (normalizedField.includes(normalizedQuery)) { - return { score: SCORE_SUBSTRING_MATCH, matchType: 'substring' } - } - - // No match - return { score: 0, matchType: null } -} - -/** - * Check if query matches any alias in the item's aliases array - * Returns the alias score if a match is found, 0 otherwise - */ -function calculateAliasScore( - query: string, - aliases?: string[] -): { score: number; matchType: 'alias' | null } { - if (!aliases || aliases.length === 0) { - return { score: 0, matchType: null } - } - - const normalizedQuery = query.toLowerCase().trim() - - for (const alias of aliases) { - const normalizedAlias = alias.toLowerCase().trim() - - if (normalizedAlias === normalizedQuery) { - return { score: SCORE_ALIAS_MATCH, matchType: 'alias' } - } - - if (normalizedAlias.startsWith(normalizedQuery)) { - return { score: SCORE_ALIAS_MATCH * 0.8, matchType: 'alias' } - } - - if (normalizedQuery.includes(normalizedAlias) || normalizedAlias.includes(normalizedQuery)) { - return { score: SCORE_ALIAS_MATCH * 0.6, matchType: 'alias' } - } - } - - return { score: 0, matchType: null } -} - -/** - * Calculate multi-word match score - * Each word in the query must appear somewhere in the field - * Returns a score based on how well the words match - */ -function calculateMultiWordScore( - queryWords: string[], - field: string -): { score: number; matchType: 'word-boundary' | 'substring' | null } { - const normalizedField = field.toLowerCase().trim() - const fieldWords = normalizedField.split(/[\s\-_/:]+/) - - let allWordsMatch = true - let totalScore = 0 - let hasWordBoundary = false - - for (const queryWord of queryWords) { - const wordBoundaryMatch = fieldWords.some((fw) => fw.startsWith(queryWord)) - const substringMatch = normalizedField.includes(queryWord) - - if (wordBoundaryMatch) { - totalScore += SCORE_WORD_BOUNDARY - hasWordBoundary = true - } else if (substringMatch) { - totalScore += SCORE_SUBSTRING_MATCH - } else { - allWordsMatch = false - break - } - } - - if (!allWordsMatch) { - return { score: 0, matchType: null } - } - - return { - score: totalScore / queryWords.length, - matchType: hasWordBoundary ? 'word-boundary' : 'substring', - } -} - -/** - * Search items using tiered matching algorithm - * Returns items sorted by relevance (highest score first) - */ -export function searchItems( - query: string, - items: T[] -): SearchResult[] { - const normalizedQuery = query.trim() - - if (!normalizedQuery) { - return [] - } - - const results: SearchResult[] = [] - const queryWords = normalizedQuery.toLowerCase().split(/\s+/).filter(Boolean) - const isMultiWord = queryWords.length > 1 - - for (const item of items) { - const nameMatch = calculateFieldScore(normalizedQuery, item.name) - - const descMatch = item.description - ? calculateFieldScore(normalizedQuery, item.description) - : { score: 0, matchType: null } - - const aliasMatch = calculateAliasScore(normalizedQuery, item.aliases) - - let nameScore = nameMatch.score - let descScore = descMatch.score * DESCRIPTION_WEIGHT - const aliasScore = aliasMatch.score - - let bestMatchType = nameMatch.matchType - - // For multi-word queries, also try matching each word independently and take the better score - if (isMultiWord) { - const multiWordNameMatch = calculateMultiWordScore(queryWords, item.name) - if (multiWordNameMatch.score > nameScore) { - nameScore = multiWordNameMatch.score - bestMatchType = multiWordNameMatch.matchType - } - - if (item.description) { - const multiWordDescMatch = calculateMultiWordScore(queryWords, item.description) - const multiWordDescScore = multiWordDescMatch.score * DESCRIPTION_WEIGHT - if (multiWordDescScore > descScore) { - descScore = multiWordDescScore - } - } - } - - const bestScore = Math.max(nameScore, descScore, aliasScore) - - if (bestScore > 0) { - let matchType: SearchResult['matchType'] = 'substring' - if (nameScore >= descScore && nameScore >= aliasScore) { - matchType = bestMatchType || 'substring' - } else if (aliasScore >= descScore) { - matchType = 'alias' - } else { - matchType = 'description' - } - - results.push({ - item, - score: bestScore, - matchType, - }) - } - } - - results.sort((a, b) => b.score - a.score) - - return results -} - -/** - * Get a human-readable match type label - */ -export function getMatchTypeLabel(matchType: SearchResult['matchType']): string { - switch (matchType) { - case 'exact': - return 'Exact match' - case 'prefix': - return 'Starts with' - case 'alias': - return 'Similar to' - case 'word-boundary': - return 'Word match' - case 'substring': - return 'Contains' - case 'description': - return 'In description' - default: - return 'Match' - } -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/mcp.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/mcp.tsx index e1f2ea3a7e..d4103702bc 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/mcp.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/mcp.tsx @@ -176,7 +176,7 @@ function FormattedInput({ onChange, onScroll, }: FormattedInputProps) { - const handleScroll = (e: React.UIEvent) => { + const handleScroll = (e: { currentTarget: HTMLInputElement }) => { onScroll(e.currentTarget.scrollLeft) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index 5d5f36dc38..407e8b9caf 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -73,7 +73,12 @@ export const Sidebar = memo(function Sidebar() { const { data: sessionData, isPending: sessionLoading } = useSession() const { canEdit } = useUserPermissionsContext() - const { config: permissionConfig } = usePermissionConfig() + const { config: permissionConfig, filterBlocks } = usePermissionConfig() + const initializeSearchData = useSearchModalStore((state) => state.initializeData) + + useEffect(() => { + initializeSearchData(filterBlocks) + }, [initializeSearchData, filterBlocks]) /** * Sidebar state from store with hydration tracking to prevent SSR mismatch. diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 9c901860a4..f9b47f43c6 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -1,4 +1,5 @@ import type { SVGProps } from 'react' +import { useId } from 'react' export function SearchIcon(props: SVGProps) { return ( @@ -737,6 +738,9 @@ export function GmailIcon(props: SVGProps) { } export function GrafanaIcon(props: SVGProps) { + const id = useId() + const gradientId = `grafana_gradient_${id}` + return ( ) { fill='none' > ) { } export function SupabaseIcon(props: SVGProps) { + const id = useId() + const gradient0 = `supabase_paint0_${id}` + const gradient1 = `supabase_paint1_${id}` + return ( ) { > ) { /> ) { ) { } export function MistralIcon(props: SVGProps) { + const id = useId() + const clipId = `mistral_clip_${id}` + return ( ) { xmlns='http://www.w3.org/2000/svg' preserveAspectRatio='xMidYMid meet' > - + ) { /> - + @@ -2126,6 +2137,9 @@ export function MicrosoftIcon(props: SVGProps) { } export function MicrosoftTeamsIcon(props: SVGProps) { + const id = useId() + const gradientId = `msteams_gradient_${id}` + return ( ) { d='M1140.333,561.355v103.148c-104.963-24.857-191.679-98.469-233.25-198.003 h138.395C1097.783,466.699,1140.134,509.051,1140.333,561.355z' /> ) { ) { } export function OutlookIcon(props: SVGProps) { + const id = useId() + const gradient1 = `outlook_gradient1_${id}` + const gradient2 = `outlook_gradient2_${id}` + return ( ) { ) { ) { d='M936.833,461.305v823.136c-0.046,43.067-34.861,78.015-77.927,78.225H425.833 V383.25h433.072c43.062,0.023,77.951,34.951,77.927,78.013C936.833,461.277,936.833,461.291,936.833,461.305z' /> ) { ) { } export function MicrosoftExcelIcon(props: SVGProps) { + const id = useId() + const gradientId = `excel_gradient_${id}` + return ( ) { d='M1073.893,479.25H532.5V1704h541.393c53.834-0.175,97.432-43.773,97.607-97.607 V576.857C1171.325,523.023,1127.727,479.425,1073.893,479.25z' /> ) { ) => ( ) -export const AzureIcon = (props: SVGProps) => ( - - - - - - - - - - - - - - - - - - - - - - - -) +export function AzureIcon(props: SVGProps) { + const id = useId() + const gradient0 = `azure_paint0_${id}` + const gradient1 = `azure_paint1_${id}` + const gradient2 = `azure_paint2_${id}` + + return ( + + + + + + + + + + + + + + + + + + + + + + + + ) +} export const GroqIcon = (props: SVGProps) => ( ) => ( ) -export const GeminiIcon = (props: SVGProps) => ( - - Gemini - - - - - - - - - -) +export function GeminiIcon(props: SVGProps) { + const id = useId() + const gradientId = `gemini_gradient_${id}` + + return ( + + Gemini + + + + + + + + + + ) +} export const VertexIcon = (props: SVGProps) => ( ) { } export function QdrantIcon(props: SVGProps) { + const id = useId() + const gradientId = `qdrant_gradient_${id}` + const clipPathId = `qdrant_clippath_${id}` + return ( - + ) { /> ) { - + @@ -3254,28 +3291,33 @@ export const SOC2BadgeIcon = (props: SVGProps) => ( ) -export const HIPAABadgeIcon = (props: SVGProps) => ( - - - - - - - - - - - - -) +export function HIPAABadgeIcon(props: SVGProps) { + const id = useId() + const clipId = `hipaa_clip_${id}` + + return ( + + + + + + + + + + + + + ) +} export function GoogleFormsIcon(props: SVGProps) { return ( @@ -3292,19 +3334,6 @@ export function GoogleFormsIcon(props: SVGProps) { d='M19.229 50.292h16.271v-2.959H19.229v2.959Zm0-17.75v2.958h16.271v-2.958H19.229Zm-3.698 1.479c0 1.224-0.995 2.219-2.219 2.219s-2.219-0.995-2.219-2.219c0-1.224 0.995-2.219 2.219-2.219s2.219 0.995 2.219 2.219Zm0 7.396c0 1.224-0.995 2.219-2.219 2.219s-2.219-0.995-2.219-2.219c0-1.224 0.995-2.219 2.219-2.219s2.219 0.995 2.219 2.219Zm0 7.396c0 1.224-0.995 2.219-2.219 2.219s-2.219-0.995-2.219-2.219c0-1.224 0.995-2.219 2.219-2.219s2.219 0.995 2.219 2.219Zm3.698-5.917h16.271v-2.959H19.229v2.959Z' fill='#F1F1F1' /> - - - - - - ) } @@ -3753,6 +3782,9 @@ export function SentryIcon(props: SVGProps) { } export function IncidentioIcon(props: SVGProps) { + const id = useId() + const clipId = `incidentio_clip_${id}` + return ( ) { fill='none' xmlns='http://www.w3.org/2000/svg' > - + ) { /> - + @@ -3995,6 +4027,9 @@ export function SftpIcon(props: SVGProps) { } export function ApifyIcon(props: SVGProps) { + const id = useId() + const clipId = `apify_clip_${id}` + return ( ) { fill='none' xmlns='http://www.w3.org/2000/svg' > - + ) { /> - + @@ -4128,6 +4163,9 @@ export function TextractIcon(props: SVGProps) { } export function McpIcon(props: SVGProps) { + const id = useId() + const clipId = `mcp_clip_${id}` + return ( ) { fill='none' xmlns='http://www.w3.org/2000/svg' > - + ) { /> - + @@ -4478,6 +4516,10 @@ export function GrainIcon(props: SVGProps) { } export function CirclebackIcon(props: SVGProps) { + const id = useId() + const patternId = `circleback_pattern_${id}` + const imageId = `circleback_image_${id}` + return ( ) { xmlns='http://www.w3.org/2000/svg' xmlnsXlink='http://www.w3.org/1999/xlink' > - + - - + + ) { } export function FirefliesIcon(props: SVGProps) { + const id = useId() + const g1 = `fireflies_g1_${id}` + const g2 = `fireflies_g2_${id}` + const g3 = `fireflies_g3_${id}` + const g4 = `fireflies_g4_${id}` + const g5 = `fireflies_g5_${id}` + const g6 = `fireflies_g6_${id}` + const g7 = `fireflies_g7_${id}` + const g8 = `fireflies_g8_${id}` + return ( ) { ) { ) { ) { ) { ) { ) { ) { - - + + - - + + @@ -4695,10 +4747,13 @@ export function FirefliesIcon(props: SVGProps) { } export function BedrockIcon(props: SVGProps) { + const id = useId() + const gradientId = `bedrock_gradient_${id}` + return ( - + @@ -4706,7 +4761,7 @@ export function BedrockIcon(props: SVGProps) { diff --git a/apps/sim/stores/modals/search/store.ts b/apps/sim/stores/modals/search/store.ts index c41e20f7ad..624d35e424 100644 --- a/apps/sim/stores/modals/search/store.ts +++ b/apps/sim/stores/modals/search/store.ts @@ -1,15 +1,155 @@ +import { RepeatIcon, SplitIcon } from 'lucide-react' import { create } from 'zustand' -import type { SearchModalState } from './types' - -export const useSearchModalStore = create((set) => ({ - isOpen: false, - setOpen: (open: boolean) => { - set({ isOpen: open }) - }, - open: () => { - set({ isOpen: true }) - }, - close: () => { - set({ isOpen: false }) - }, -})) +import { devtools } from 'zustand/middleware' +import { getToolOperationsIndex } from '@/lib/search/tool-operations' +import { getTriggersForSidebar } from '@/lib/workflows/triggers/trigger-utils' +import { getAllBlocks } from '@/blocks' +import type { + SearchBlockItem, + SearchData, + SearchDocItem, + SearchModalState, + SearchToolOperationItem, +} from './types' + +const initialData: SearchData = { + blocks: [], + tools: [], + triggers: [], + toolOperations: [], + docs: [], + isInitialized: false, +} + +export const useSearchModalStore = create()( + devtools( + (set, get) => ({ + isOpen: false, + data: initialData, + + setOpen: (open: boolean) => { + set({ isOpen: open }) + }, + + open: () => { + set({ isOpen: true }) + }, + + close: () => { + set({ isOpen: false }) + }, + + initializeData: (filterBlocks) => { + const allBlocks = getAllBlocks() + const filteredAllBlocks = filterBlocks(allBlocks) as typeof allBlocks + + const regularBlocks: SearchBlockItem[] = [] + const tools: SearchBlockItem[] = [] + const docs: SearchDocItem[] = [] + + for (const block of filteredAllBlocks) { + if (block.hideFromToolbar) continue + + const searchItem: SearchBlockItem = { + id: block.type, + name: block.name, + description: block.description || '', + icon: block.icon, + bgColor: block.bgColor || '#6B7280', + type: block.type, + } + + if (block.category === 'blocks' && block.type !== 'starter') { + regularBlocks.push(searchItem) + } else if (block.category === 'tools') { + tools.push(searchItem) + } + + if (block.docsLink) { + docs.push({ + id: `docs-${block.type}`, + name: block.name, + icon: block.icon, + href: block.docsLink, + }) + } + } + + const specialBlocks: SearchBlockItem[] = [ + { + id: 'loop', + name: 'Loop', + description: 'Create a Loop', + icon: RepeatIcon, + bgColor: '#2FB3FF', + type: 'loop', + }, + { + id: 'parallel', + name: 'Parallel', + description: 'Parallel Execution', + icon: SplitIcon, + bgColor: '#FEE12B', + type: 'parallel', + }, + ] + + const blocks = [...regularBlocks, ...(filterBlocks(specialBlocks) as SearchBlockItem[])] + + const allTriggers = getTriggersForSidebar() + const filteredTriggers = filterBlocks(allTriggers) as typeof allTriggers + const priorityOrder = ['Start', 'Schedule', 'Webhook'] + + const sortedTriggers = [...filteredTriggers].sort((a, b) => { + const aIndex = priorityOrder.indexOf(a.name) + const bIndex = priorityOrder.indexOf(b.name) + const aHasPriority = aIndex !== -1 + const bHasPriority = bIndex !== -1 + + if (aHasPriority && bHasPriority) return aIndex - bIndex + if (aHasPriority) return -1 + if (bHasPriority) return 1 + return a.name.localeCompare(b.name) + }) + + const triggers = sortedTriggers.map( + (block): SearchBlockItem => ({ + id: block.type, + name: block.name, + description: block.description || '', + icon: block.icon, + bgColor: block.bgColor || '#6B7280', + type: block.type, + config: block, + }) + ) + + const allowedBlockTypes = new Set(tools.map((t) => t.type)) + const toolOperations: SearchToolOperationItem[] = getToolOperationsIndex() + .filter((op) => allowedBlockTypes.has(op.blockType)) + .map((op) => ({ + id: op.id, + name: `${op.serviceName}: ${op.operationName}`, + searchValue: `${op.serviceName} ${op.operationName}`, + icon: op.icon, + bgColor: op.bgColor, + blockType: op.blockType, + operationId: op.operationId, + keywords: op.aliases, + })) + + set({ + data: { + blocks, + tools, + triggers, + toolOperations, + docs, + isInitialized: true, + }, + }) + }, + }), + { name: 'search-modal-store' } + ) +) diff --git a/apps/sim/stores/modals/search/types.ts b/apps/sim/stores/modals/search/types.ts index c3170e9518..07dde9d09b 100644 --- a/apps/sim/stores/modals/search/types.ts +++ b/apps/sim/stores/modals/search/types.ts @@ -1,3 +1,55 @@ +import type { ComponentType } from 'react' +import type { BlockConfig } from '@/blocks/types' + +/** + * Represents a block item in the search results. + */ +export interface SearchBlockItem { + id: string + name: string + description: string + icon: ComponentType<{ className?: string }> + bgColor: string + type: string + config?: BlockConfig +} + +/** + * Represents a tool operation item in the search results. + */ +export interface SearchToolOperationItem { + id: string + name: string + searchValue: string + icon: ComponentType<{ className?: string }> + bgColor: string + blockType: string + operationId: string + keywords: string[] +} + +/** + * Represents a doc item in the search results. + */ +export interface SearchDocItem { + id: string + name: string + icon: ComponentType<{ className?: string }> + href: string +} + +/** + * Pre-computed search data that is initialized on app load. + */ +export interface SearchData { + blocks: SearchBlockItem[] + tools: SearchBlockItem[] + triggers: SearchBlockItem[] + toolOperations: SearchToolOperationItem[] + docs: SearchDocItem[] + isInitialized: boolean +} + /** * Global state for the universal search modal. * @@ -8,18 +60,27 @@ export interface SearchModalState { /** Whether the search modal is currently open. */ isOpen: boolean + + /** Pre-computed search data. */ + data: SearchData + /** * Explicitly set the open state of the modal. - * - * @param open - New open state. */ setOpen: (open: boolean) => void + /** * Convenience method to open the modal. */ open: () => void + /** * Convenience method to close the modal. */ close: () => void + + /** + * Initialize search data. Called once on app load. + */ + initializeData: (filterBlocks: (blocks: T[]) => T[]) => void } From 6814f332434c484ba69c3abf61345926ca200a25 Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 28 Jan 2026 10:51:19 -0800 Subject: [PATCH 02/20] fix(helm): move rotationPolicy under privateKey for cert-manager compatibility (#3046) * fix(helm): move rotationPolicy under privateKey for cert-manager compatibility * docs(helm): add reclaimPolicy Retain guidance for production database storage * fix(helm): prevent empty branding ConfigMap creation --- helm/sim/examples/values-azure.yaml | 5 +++-- helm/sim/examples/values-production.yaml | 1 + helm/sim/templates/certificate-postgresql.yaml | 6 +++--- helm/sim/templates/configmap-branding.yaml | 2 +- helm/sim/templates/deployment-app.yaml | 10 ++++++---- 5 files changed, 14 insertions(+), 10 deletions(-) diff --git a/helm/sim/examples/values-azure.yaml b/helm/sim/examples/values-azure.yaml index 982605fa7b..a11b55adc9 100644 --- a/helm/sim/examples/values-azure.yaml +++ b/helm/sim/examples/values-azure.yaml @@ -4,8 +4,9 @@ # Global configuration global: imageRegistry: "ghcr.io" - # Use "managed-csi-premium" for Premium SSD (requires Premium storage-capable VMs like Standard_DS*) - # Use "managed-csi" for Standard SSD (works with all VM types) + # Use "managed-csi-premium" for Premium SSD, "managed-csi" for Standard SSD + # IMPORTANT: For production, use a StorageClass with reclaimPolicy: Retain + # to protect database volumes from accidental deletion. storageClass: "managed-csi" # Main application diff --git a/helm/sim/examples/values-production.yaml b/helm/sim/examples/values-production.yaml index 794afa4ac0..9874cb1a51 100644 --- a/helm/sim/examples/values-production.yaml +++ b/helm/sim/examples/values-production.yaml @@ -4,6 +4,7 @@ # Global configuration global: imageRegistry: "ghcr.io" + # For production, use a StorageClass with reclaimPolicy: Retain storageClass: "managed-csi-premium" # Main application diff --git a/helm/sim/templates/certificate-postgresql.yaml b/helm/sim/templates/certificate-postgresql.yaml index bbe390adf5..84f507cafd 100644 --- a/helm/sim/templates/certificate-postgresql.yaml +++ b/helm/sim/templates/certificate-postgresql.yaml @@ -11,12 +11,12 @@ spec: duration: {{ .Values.postgresql.tls.duration | default "87600h" }} # Default: 10 years renewBefore: {{ .Values.postgresql.tls.renewBefore | default "2160h" }} # Default: 90 days before expiry isCA: false - {{- if .Values.postgresql.tls.rotationPolicy }} - rotationPolicy: {{ .Values.postgresql.tls.rotationPolicy }} - {{- end }} privateKey: algorithm: {{ .Values.postgresql.tls.privateKey.algorithm | default "RSA" }} size: {{ .Values.postgresql.tls.privateKey.size | default 4096 }} + {{- if .Values.postgresql.tls.rotationPolicy }} + rotationPolicy: {{ .Values.postgresql.tls.rotationPolicy }} + {{- end }} usages: - server auth - client auth diff --git a/helm/sim/templates/configmap-branding.yaml b/helm/sim/templates/configmap-branding.yaml index 4e22d3a2b5..ae05c4dd82 100644 --- a/helm/sim/templates/configmap-branding.yaml +++ b/helm/sim/templates/configmap-branding.yaml @@ -1,4 +1,4 @@ -{{- if .Values.branding.enabled }} +{{- if and .Values.branding.enabled (or .Values.branding.files .Values.branding.binaryFiles) }} --- # Branding ConfigMap # Mounts custom branding assets (logos, CSS, etc.) into the application diff --git a/helm/sim/templates/deployment-app.yaml b/helm/sim/templates/deployment-app.yaml index 5362dd43e8..31be48aa37 100644 --- a/helm/sim/templates/deployment-app.yaml +++ b/helm/sim/templates/deployment-app.yaml @@ -110,9 +110,10 @@ spec: {{- end }} {{- include "sim.resources" .Values.app | nindent 10 }} {{- include "sim.securityContext" .Values.app | nindent 10 }} - {{- if or .Values.branding.enabled .Values.extraVolumeMounts .Values.app.extraVolumeMounts }} + {{- $hasBranding := and .Values.branding.enabled (or .Values.branding.files .Values.branding.binaryFiles) }} + {{- if or $hasBranding .Values.extraVolumeMounts .Values.app.extraVolumeMounts }} volumeMounts: - {{- if .Values.branding.enabled }} + {{- if $hasBranding }} - name: branding mountPath: {{ .Values.branding.mountPath | default "/app/public/branding" }} readOnly: true @@ -124,9 +125,10 @@ spec: {{- toYaml . | nindent 12 }} {{- end }} {{- end }} - {{- if or .Values.branding.enabled .Values.extraVolumes .Values.app.extraVolumes }} + {{- $hasBranding := and .Values.branding.enabled (or .Values.branding.files .Values.branding.binaryFiles) }} + {{- if or $hasBranding .Values.extraVolumes .Values.app.extraVolumes }} volumes: - {{- if .Values.branding.enabled }} + {{- if $hasBranding }} - name: branding configMap: name: {{ include "sim.fullname" . }}-branding From 01e0723a3ade719f1b9889c92b0c75a0cca74c04 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com> Date: Wed, 28 Jan 2026 12:16:36 -0800 Subject: [PATCH 03/20] fix(loops): fix loops on empty collection (#3049) * Fix * Cleanup * order of ops for validations * only reachable subflow nodes should hit validation --------- Co-authored-by: Vikhyath Mondreti --- apps/sim/executor/dag/construction/edges.ts | 9 ++--- apps/sim/executor/execution/edge-manager.ts | 19 +++++----- apps/sim/executor/execution/engine.ts | 6 ++++ apps/sim/executor/execution/state.ts | 2 ++ apps/sim/executor/orchestrators/loop.ts | 6 ++-- apps/sim/executor/orchestrators/node.ts | 13 ++++++- apps/sim/executor/orchestrators/parallel.ts | 40 ++++++++++++++++++--- 7 files changed, 75 insertions(+), 20 deletions(-) diff --git a/apps/sim/executor/dag/construction/edges.ts b/apps/sim/executor/dag/construction/edges.ts index 4a36e5f918..ef6c238de6 100644 --- a/apps/sim/executor/dag/construction/edges.ts +++ b/apps/sim/executor/dag/construction/edges.ts @@ -207,6 +207,7 @@ export class EdgeConstructor { for (const connection of workflow.connections) { let { source, target } = connection const originalSource = source + const originalTarget = target let sourceHandle = this.generateSourceHandle( source, target, @@ -257,14 +258,14 @@ export class EdgeConstructor { target = sentinelStartId } - if (loopSentinelStartId) { - this.addEdge(dag, loopSentinelStartId, target, EDGE.LOOP_EXIT, targetHandle) - } - if (this.edgeCrossesLoopBoundary(source, target, blocksInLoops, dag)) { continue } + if (loopSentinelStartId && !blocksInLoops.has(originalTarget)) { + this.addEdge(dag, loopSentinelStartId, target, EDGE.LOOP_EXIT, targetHandle) + } + if (!this.isEdgeReachable(source, target, reachableBlocks, dag)) { continue } diff --git a/apps/sim/executor/execution/edge-manager.ts b/apps/sim/executor/execution/edge-manager.ts index f0ac33fa7f..3598bed7d3 100644 --- a/apps/sim/executor/execution/edge-manager.ts +++ b/apps/sim/executor/execution/edge-manager.ts @@ -77,15 +77,16 @@ export class EdgeManager { } } - // Check if any deactivation targets that previously received an activated edge are now ready - for (const { target } of edgesToDeactivate) { - if ( - !readyNodes.includes(target) && - !activatedTargets.includes(target) && - this.nodesWithActivatedEdge.has(target) && - this.isTargetReady(target) - ) { - readyNodes.push(target) + if (output.selectedRoute !== EDGE.LOOP_EXIT && output.selectedRoute !== EDGE.PARALLEL_EXIT) { + for (const { target } of edgesToDeactivate) { + if ( + !readyNodes.includes(target) && + !activatedTargets.includes(target) && + this.nodesWithActivatedEdge.has(target) && + this.isTargetReady(target) + ) { + readyNodes.push(target) + } } } diff --git a/apps/sim/executor/execution/engine.ts b/apps/sim/executor/execution/engine.ts index 05e7e04843..86e7fd6256 100644 --- a/apps/sim/executor/execution/engine.ts +++ b/apps/sim/executor/execution/engine.ts @@ -390,6 +390,12 @@ export class ExecutionEngine { logger.info('Processing outgoing edges', { nodeId, outgoingEdgesCount: node.outgoingEdges.size, + outgoingEdges: Array.from(node.outgoingEdges.entries()).map(([id, e]) => ({ + id, + target: e.target, + sourceHandle: e.sourceHandle, + })), + output, readyNodesCount: readyNodes.length, readyNodes, }) diff --git a/apps/sim/executor/execution/state.ts b/apps/sim/executor/execution/state.ts index 7cf849c9ef..bbbc7bc42c 100644 --- a/apps/sim/executor/execution/state.ts +++ b/apps/sim/executor/execution/state.ts @@ -27,6 +27,8 @@ export interface ParallelScope { items?: any[] /** Error message if parallel validation failed (e.g., exceeded max branches) */ validationError?: string + /** Whether the parallel has an empty distribution and should be skipped */ + isEmpty?: boolean } export class ExecutionState implements BlockStateController { diff --git a/apps/sim/executor/orchestrators/loop.ts b/apps/sim/executor/orchestrators/loop.ts index b9a5bd3351..f0757e642f 100644 --- a/apps/sim/executor/orchestrators/loop.ts +++ b/apps/sim/executor/orchestrators/loop.ts @@ -386,10 +386,10 @@ export class LoopOrchestrator { return true } - // forEach: skip if items array is empty if (scope.loopType === 'forEach') { if (!scope.items || scope.items.length === 0) { - logger.info('ForEach loop has empty items, skipping loop body', { loopId }) + logger.info('ForEach loop has empty collection, skipping loop body', { loopId }) + this.state.setBlockOutput(loopId, { results: [] }, DEFAULTS.EXECUTION_TIME) return false } return true @@ -399,6 +399,8 @@ export class LoopOrchestrator { if (scope.loopType === 'for') { if (scope.maxIterations === 0) { logger.info('For loop has 0 iterations, skipping loop body', { loopId }) + // Set empty output for the loop + this.state.setBlockOutput(loopId, { results: [] }, DEFAULTS.EXECUTION_TIME) return false } return true diff --git a/apps/sim/executor/orchestrators/node.ts b/apps/sim/executor/orchestrators/node.ts index e5d7bc1a11..7ec669bd33 100644 --- a/apps/sim/executor/orchestrators/node.ts +++ b/apps/sim/executor/orchestrators/node.ts @@ -97,7 +97,7 @@ export class NodeExecutionOrchestrator { if (loopId) { const shouldExecute = await this.loopOrchestrator.evaluateInitialCondition(ctx, loopId) if (!shouldExecute) { - logger.info('While loop initial condition false, skipping loop body', { loopId }) + logger.info('Loop initial condition false, skipping loop body', { loopId }) return { sentinelStart: true, shouldExit: true, @@ -158,6 +158,17 @@ export class NodeExecutionOrchestrator { this.parallelOrchestrator.initializeParallelScope(ctx, parallelId, nodesInParallel) } } + + const scope = this.parallelOrchestrator.getParallelScope(ctx, parallelId) + if (scope?.isEmpty) { + logger.info('Parallel has empty distribution, skipping parallel body', { parallelId }) + return { + sentinelStart: true, + shouldExit: true, + selectedRoute: EDGE.PARALLEL_EXIT, + } + } + return { sentinelStart: true } } diff --git a/apps/sim/executor/orchestrators/parallel.ts b/apps/sim/executor/orchestrators/parallel.ts index ef17d624af..12ae70f726 100644 --- a/apps/sim/executor/orchestrators/parallel.ts +++ b/apps/sim/executor/orchestrators/parallel.ts @@ -61,11 +61,13 @@ export class ParallelOrchestrator { let items: any[] | undefined let branchCount: number + let isEmpty = false try { - const resolved = this.resolveBranchCount(ctx, parallelConfig) + const resolved = this.resolveBranchCount(ctx, parallelConfig, parallelId) branchCount = resolved.branchCount items = resolved.items + isEmpty = resolved.isEmpty ?? false } catch (error) { const errorMessage = `Parallel Items did not resolve: ${error instanceof Error ? error.message : String(error)}` logger.error(errorMessage, { parallelId, distribution: parallelConfig.distribution }) @@ -91,6 +93,34 @@ export class ParallelOrchestrator { throw new Error(branchError) } + // Handle empty distribution - skip parallel body + if (isEmpty || branchCount === 0) { + const scope: ParallelScope = { + parallelId, + totalBranches: 0, + branchOutputs: new Map(), + completedCount: 0, + totalExpectedNodes: 0, + items: [], + isEmpty: true, + } + + if (!ctx.parallelExecutions) { + ctx.parallelExecutions = new Map() + } + ctx.parallelExecutions.set(parallelId, scope) + + // Set empty output for the parallel + this.state.setBlockOutput(parallelId, { results: [] }) + + logger.info('Parallel scope initialized with empty distribution, skipping body', { + parallelId, + branchCount: 0, + }) + + return scope + } + const { entryNodes } = this.expander.expandParallel(this.dag, parallelId, branchCount, items) const scope: ParallelScope = { @@ -127,15 +157,17 @@ export class ParallelOrchestrator { private resolveBranchCount( ctx: ExecutionContext, - config: SerializedParallel - ): { branchCount: number; items?: any[] } { + config: SerializedParallel, + parallelId: string + ): { branchCount: number; items?: any[]; isEmpty?: boolean } { if (config.parallelType === 'count') { return { branchCount: config.count ?? 1 } } const items = this.resolveDistributionItems(ctx, config) if (items.length === 0) { - return { branchCount: config.count ?? 1 } + logger.info('Parallel has empty distribution, skipping parallel body', { parallelId }) + return { branchCount: 0, items: [], isEmpty: true } } return { branchCount: items.length, items } From 2c2b485f818d9a356dfd49c1586c753758799672 Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 28 Jan 2026 12:31:38 -0800 Subject: [PATCH 04/20] fix(workflow): update container dimensions on keyboard movement (#3043) * fix(workflow): update container dimensions on keyboard movement * fix(workflow): avoid duplicate container updates during drag Add !change.dragging check to only handle keyboard movements in onNodesChange, since mouse drags are already handled by onNodeDrag. * fix(workflow): persist keyboard movements to backend Keyboard arrow key movements now call collaborativeBatchUpdatePositions to sync position changes to the backend for persistence and real-time collaboration. * improvement(cmdk): refactor search modal to use cmdk + fix icon SVG IDs (#3044) * improvement(cmdk): refactor search modal to use cmdk + fix icon SVG IDs * chore: remove unrelated workflow.tsx changes * chore: remove comments * chore: add devtools middleware to search modal store * fix: allow search data re-initialization when permissions change * fix: include keywords in search filter + show service name in tool operations * fix: correct filterBlocks type signature * fix: move generic to function parameter position * fix(mcp): correct event handler type for onInput * perf: always render command palette for instant opening * fix: clear search input when modal reopens * fix(helm): move rotationPolicy under privateKey for cert-manager compatibility (#3046) * fix(helm): move rotationPolicy under privateKey for cert-manager compatibility * docs(helm): add reclaimPolicy Retain guidance for production database storage * fix(helm): prevent empty branding ConfigMap creation * fix(workflow): avoid duplicate position updates on drag end Check isInDragOperation before persisting in onNodesChange to prevent duplicate calls. Drag-end events have dragStartPosition still set, while keyboard movements don't, allowing proper distinction. --- .../[workspaceId]/w/[workflowId]/workflow.tsx | 84 ++++++++++++------- 1 file changed, 56 insertions(+), 28 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 22fe3c8ce6..690aad48ca 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -2302,33 +2302,12 @@ const WorkflowContent = React.memo(() => { window.removeEventListener('remove-from-subflow', handleRemoveFromSubflow as EventListener) }, [blocks, edgesForDisplay, getNodeAbsolutePosition, collaborativeBatchUpdateParent]) - /** Handles node changes - applies changes and resolves parent-child selection conflicts. */ - const onNodesChange = useCallback( - (changes: NodeChange[]) => { - selectedIdsRef.current = null - setDisplayNodes((nds) => { - const updated = applyNodeChanges(changes, nds) - const hasSelectionChange = changes.some((c) => c.type === 'select') - if (!hasSelectionChange) return updated - const resolved = resolveParentChildSelectionConflicts(updated, blocks) - selectedIdsRef.current = resolved.filter((node) => node.selected).map((node) => node.id) - return resolved - }) - const selectedIds = selectedIdsRef.current as string[] | null - if (selectedIds !== null) { - syncPanelWithSelection(selectedIds) - } - }, - [blocks] - ) - /** - * Updates container dimensions in displayNodes during drag. - * This allows live resizing of containers as their children are dragged. + * Updates container dimensions in displayNodes during drag or keyboard movement. */ - const updateContainerDimensionsDuringDrag = useCallback( - (draggedNodeId: string, draggedNodePosition: { x: number; y: number }) => { - const parentId = blocks[draggedNodeId]?.data?.parentId + const updateContainerDimensionsDuringMove = useCallback( + (movedNodeId: string, movedNodePosition: { x: number; y: number }) => { + const parentId = blocks[movedNodeId]?.data?.parentId if (!parentId) return setDisplayNodes((currentNodes) => { @@ -2336,7 +2315,7 @@ const WorkflowContent = React.memo(() => { if (childNodes.length === 0) return currentNodes const childPositions = childNodes.map((node) => { - const nodePosition = node.id === draggedNodeId ? draggedNodePosition : node.position + const nodePosition = node.id === movedNodeId ? movedNodePosition : node.position const { width, height } = getBlockDimensions(node.id) return { x: nodePosition.x, y: nodePosition.y, width, height } }) @@ -2367,6 +2346,55 @@ const WorkflowContent = React.memo(() => { [blocks, getBlockDimensions] ) + /** Handles node changes - applies changes and resolves parent-child selection conflicts. */ + const onNodesChange = useCallback( + (changes: NodeChange[]) => { + selectedIdsRef.current = null + setDisplayNodes((nds) => { + const updated = applyNodeChanges(changes, nds) + const hasSelectionChange = changes.some((c) => c.type === 'select') + if (!hasSelectionChange) return updated + const resolved = resolveParentChildSelectionConflicts(updated, blocks) + selectedIdsRef.current = resolved.filter((node) => node.selected).map((node) => node.id) + return resolved + }) + const selectedIds = selectedIdsRef.current as string[] | null + if (selectedIds !== null) { + syncPanelWithSelection(selectedIds) + } + + // Handle position changes (e.g., from keyboard arrow key movement) + // Update container dimensions when child nodes are moved and persist to backend + // Only persist if not in a drag operation (drag-end is handled by onNodeDragStop) + const isInDragOperation = + getDragStartPosition() !== null || multiNodeDragStartRef.current.size > 0 + const keyboardPositionUpdates: Array<{ id: string; position: { x: number; y: number } }> = [] + for (const change of changes) { + if ( + change.type === 'position' && + !change.dragging && + 'position' in change && + change.position + ) { + updateContainerDimensionsDuringMove(change.id, change.position) + if (!isInDragOperation) { + keyboardPositionUpdates.push({ id: change.id, position: change.position }) + } + } + } + // Persist keyboard movements to backend for collaboration sync + if (keyboardPositionUpdates.length > 0) { + collaborativeBatchUpdatePositions(keyboardPositionUpdates) + } + }, + [ + blocks, + updateContainerDimensionsDuringMove, + collaborativeBatchUpdatePositions, + getDragStartPosition, + ] + ) + /** * Effect to resize loops when nodes change (add/remove/position change). * Runs on structural changes only - not during drag (position-only changes). @@ -2611,7 +2639,7 @@ const WorkflowContent = React.memo(() => { // If the node is inside a container, update container dimensions during drag if (currentParentId) { - updateContainerDimensionsDuringDrag(node.id, node.position) + updateContainerDimensionsDuringMove(node.id, node.position) } // Check if this is a starter block - starter blocks should never be in containers @@ -2728,7 +2756,7 @@ const WorkflowContent = React.memo(() => { blocks, getNodeAbsolutePosition, getNodeDepth, - updateContainerDimensionsDuringDrag, + updateContainerDimensionsDuringMove, highlightContainerNode, ] ) From 72a2f79701addaf2034418d1a02bbb8144392f90 Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 28 Jan 2026 12:39:00 -0800 Subject: [PATCH 05/20] improvement(search-modal): add quick navigation items and fix cmdk value uniqueness (#3050) * improvement(search-modal): add quick navigation items and fix cmdk value uniqueness * rerank --- .../components/search-modal/search-modal.tsx | 65 +++++++++++++------ apps/sim/stores/modals/search/store.ts | 2 +- 2 files changed, 46 insertions(+), 21 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx index 732b4056e7..163eb49f4e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx @@ -2,9 +2,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Command } from 'cmdk' -import { BookOpen, Layout, ScrollText } from 'lucide-react' +import { Database, HelpCircle, Layout, Settings } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' import { createPortal } from 'react-dom' +import { Library } from '@/components/emcn' import { useBrandConfig } from '@/lib/branding/branding' import { cn } from '@/lib/core/utils/cn' import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils' @@ -15,6 +16,7 @@ import type { SearchDocItem, SearchToolOperationItem, } from '@/stores/modals/search/types' +import { useSettingsModalStore } from '@/stores/modals/settings/store' interface SearchModalProps { open: boolean @@ -43,7 +45,8 @@ interface PageItem { id: string name: string icon: React.ComponentType<{ className?: string }> - href: string + href?: string + onClick?: () => void shortcut?: string } @@ -61,6 +64,7 @@ export function SearchModal({ const inputRef = useRef(null) const [search, setSearch] = useState('') const [mounted, setMounted] = useState(false) + const openSettingsModal = useSettingsModalStore((state) => state.openModal) useEffect(() => { setMounted(true) @@ -70,12 +74,16 @@ export function SearchModal({ (state) => state.data ) + const openHelpModal = useCallback(() => { + window.dispatchEvent(new CustomEvent('open-help-modal')) + }, []) + const pages = useMemo( (): PageItem[] => [ { id: 'logs', name: 'Logs', - icon: ScrollText, + icon: Library, href: `/workspace/${workspaceId}/logs`, shortcut: '⌘⇧L', }, @@ -86,13 +94,26 @@ export function SearchModal({ href: `/workspace/${workspaceId}/templates`, }, { - id: 'docs', - name: 'Docs', - icon: BookOpen, - href: brand.documentationUrl || 'https://docs.sim.ai/', + id: 'knowledge-base', + name: 'Knowledge Base', + icon: Database, + href: `/workspace/${workspaceId}/knowledge`, + }, + { + id: 'help', + name: 'Help', + icon: HelpCircle, + onClick: openHelpModal, + }, + { + id: 'settings', + name: 'Settings', + icon: Settings, + onClick: openSettingsModal, + shortcut: '⌘,', }, ], - [workspaceId, brand.documentationUrl] + [workspaceId, openHelpModal, openSettingsModal] ) useEffect(() => { @@ -179,10 +200,14 @@ export function SearchModal({ const handlePageSelect = useCallback( (page: PageItem) => { - if (page.href.startsWith('http')) { - window.open(page.href, '_blank', 'noopener,noreferrer') - } else { - router.push(page.href) + if (page.onClick) { + page.onClick() + } else if (page.href) { + if (page.href.startsWith('http')) { + window.open(page.href, '_blank', 'noopener,noreferrer') + } else { + router.push(page.href) + } } onOpenChange(false) }, @@ -269,7 +294,7 @@ export function SearchModal({ {blocks.map((block) => ( handleBlockSelect(block, 'block')} icon={block.icon} @@ -287,7 +312,7 @@ export function SearchModal({ {tools.map((tool) => ( handleBlockSelect(tool, 'tool')} icon={tool.icon} @@ -305,7 +330,7 @@ export function SearchModal({ {triggers.map((trigger) => ( handleBlockSelect(trigger, 'trigger')} icon={trigger.icon} @@ -323,7 +348,7 @@ export function SearchModal({ {workflows.map((workflow) => ( handleWorkflowSelect(workflow)} className='group flex h-[28px] w-full cursor-pointer items-center gap-[8px] rounded-[6px] px-[10px] text-left text-[15px] aria-selected:bg-[var(--border)] aria-selected:shadow-sm data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50' > @@ -345,7 +370,7 @@ export function SearchModal({ {toolOperations.map((op) => ( handleToolOperationSelect(op)} icon={op.icon} @@ -363,7 +388,7 @@ export function SearchModal({ {workspaces.map((workspace) => ( handleWorkspaceSelect(workspace)} className='group flex h-[28px] w-full cursor-pointer items-center gap-[8px] rounded-[6px] px-[10px] text-left text-[15px] aria-selected:bg-[var(--border)] aria-selected:shadow-sm data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50' > @@ -381,7 +406,7 @@ export function SearchModal({ {docs.map((doc) => ( handleDocSelect(doc)} icon={doc.icon} bgColor='#6B7280' @@ -400,7 +425,7 @@ export function SearchModal({ return ( handlePageSelect(page)} className='group flex h-[28px] w-full cursor-pointer items-center gap-[8px] rounded-[6px] px-[10px] text-left text-[15px] aria-selected:bg-[var(--border)] aria-selected:shadow-sm data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50' > diff --git a/apps/sim/stores/modals/search/store.ts b/apps/sim/stores/modals/search/store.ts index 624d35e424..ac591e7b38 100644 --- a/apps/sim/stores/modals/search/store.ts +++ b/apps/sim/stores/modals/search/store.ts @@ -129,7 +129,7 @@ export const useSearchModalStore = create()( .filter((op) => allowedBlockTypes.has(op.blockType)) .map((op) => ({ id: op.id, - name: `${op.serviceName}: ${op.operationName}`, + name: op.operationName, searchValue: `${op.serviceName} ${op.operationName}`, icon: op.icon, bgColor: op.bgColor, From 655fe4f3b709ebbdfa4ca6a5121080543ef3117a Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com> Date: Wed, 28 Jan 2026 12:53:23 -0800 Subject: [PATCH 06/20] feat(executor): run from/until block (#3029) * Run from block * Fixes * Fix * Fix * Minor improvements * Fix * Fix trace spans * Fix loop l ogs * Change ordering * Run u ntil block * Lint * Clean up * Fix * Allow run from block for triggers * Consolidation * Fix lint * Fix * Fix mock payload * Fix * Fix trigger clear snapshot * Fix loops and parallels * Fix * Cleanup * Fix test * Fix bugs * Catch error * Fix * Fix * I think it works?? * Fix * Fix * Add tests * Fix lint --------- Co-authored-by: Vikhyath Mondreti --- .../[id]/execute-from-block/route.ts | 216 +++ .../app/api/workflows/[id]/execute/route.ts | 5 + .../components/action-bar/action-bar.tsx | 51 +- .../components/block-menu/block-menu.tsx | 51 +- .../hooks/use-workflow-execution.ts | 402 ++++- .../[workspaceId]/w/[workflowId]/workflow.tsx | 72 +- apps/sim/executor/dag/builder.ts | 19 +- apps/sim/executor/dag/construction/paths.ts | 11 +- apps/sim/executor/execution/engine.ts | 24 +- apps/sim/executor/execution/executor.ts | 179 +- apps/sim/executor/execution/types.ts | 12 + apps/sim/executor/orchestrators/loop.ts | 11 +- apps/sim/executor/orchestrators/node.ts | 13 +- apps/sim/executor/orchestrators/parallel.ts | 14 +- apps/sim/executor/types.ts | 12 + .../sim/executor/utils/run-from-block.test.ts | 1537 +++++++++++++++++ apps/sim/executor/utils/run-from-block.ts | 219 +++ apps/sim/hooks/use-execution-stream.ts | 229 ++- .../lib/workflows/executor/execution-core.ts | 32 +- .../workflows/executor/execution-events.ts | 137 ++ apps/sim/stores/execution/store.ts | 19 + apps/sim/stores/execution/types.ts | 14 +- 22 files changed, 3143 insertions(+), 136 deletions(-) create mode 100644 apps/sim/app/api/workflows/[id]/execute-from-block/route.ts create mode 100644 apps/sim/executor/utils/run-from-block.test.ts create mode 100644 apps/sim/executor/utils/run-from-block.ts diff --git a/apps/sim/app/api/workflows/[id]/execute-from-block/route.ts b/apps/sim/app/api/workflows/[id]/execute-from-block/route.ts new file mode 100644 index 0000000000..647012589c --- /dev/null +++ b/apps/sim/app/api/workflows/[id]/execute-from-block/route.ts @@ -0,0 +1,216 @@ +import { db, workflow as workflowTable } from '@sim/db' +import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { v4 as uuidv4 } from 'uuid' +import { z } from 'zod' +import { checkHybridAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { SSE_HEADERS } from '@/lib/core/utils/sse' +import { markExecutionCancelled } from '@/lib/execution/cancellation' +import { LoggingSession } from '@/lib/logs/execution/logging-session' +import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core' +import { createSSECallbacks } from '@/lib/workflows/executor/execution-events' +import { ExecutionSnapshot } from '@/executor/execution/snapshot' +import type { ExecutionMetadata, SerializableExecutionState } from '@/executor/execution/types' +import { hasExecutionResult } from '@/executor/utils/errors' + +const logger = createLogger('ExecuteFromBlockAPI') + +const ExecuteFromBlockSchema = z.object({ + startBlockId: z.string().min(1, 'Start block ID is required'), + sourceSnapshot: z.object({ + blockStates: z.record(z.any()), + executedBlocks: z.array(z.string()), + blockLogs: z.array(z.any()), + decisions: z.object({ + router: z.record(z.string()), + condition: z.record(z.string()), + }), + completedLoops: z.array(z.string()), + loopExecutions: z.record(z.any()).optional(), + parallelExecutions: z.record(z.any()).optional(), + parallelBlockMapping: z.record(z.any()).optional(), + activeExecutionPath: z.array(z.string()), + }), + input: z.any().optional(), +}) + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const requestId = generateRequestId() + const { id: workflowId } = await params + + try { + const auth = await checkHybridAuth(req, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + const userId = auth.userId + + let body: unknown + try { + body = await req.json() + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) + } + + const validation = ExecuteFromBlockSchema.safeParse(body) + if (!validation.success) { + logger.warn(`[${requestId}] Invalid request body:`, validation.error.errors) + return NextResponse.json( + { + error: 'Invalid request body', + details: validation.error.errors.map((e) => ({ + path: e.path.join('.'), + message: e.message, + })), + }, + { status: 400 } + ) + } + + const { startBlockId, sourceSnapshot, input } = validation.data + const executionId = uuidv4() + + const [workflowRecord] = await db + .select({ workspaceId: workflowTable.workspaceId, userId: workflowTable.userId }) + .from(workflowTable) + .where(eq(workflowTable.id, workflowId)) + .limit(1) + + if (!workflowRecord?.workspaceId) { + return NextResponse.json({ error: 'Workflow not found or has no workspace' }, { status: 404 }) + } + + const workspaceId = workflowRecord.workspaceId + const workflowUserId = workflowRecord.userId + + logger.info(`[${requestId}] Starting run-from-block execution`, { + workflowId, + startBlockId, + executedBlocksCount: sourceSnapshot.executedBlocks.length, + }) + + const loggingSession = new LoggingSession(workflowId, executionId, 'manual', requestId) + const abortController = new AbortController() + let isStreamClosed = false + + const stream = new ReadableStream({ + async start(controller) { + const { sendEvent, onBlockStart, onBlockComplete, onStream } = createSSECallbacks({ + executionId, + workflowId, + controller, + isStreamClosed: () => isStreamClosed, + setStreamClosed: () => { + isStreamClosed = true + }, + }) + + const metadata: ExecutionMetadata = { + requestId, + workflowId, + userId, + executionId, + triggerType: 'manual', + workspaceId, + workflowUserId, + useDraftState: true, + isClientSession: true, + startTime: new Date().toISOString(), + } + + const snapshot = new ExecutionSnapshot(metadata, {}, input || {}, {}) + + try { + const startTime = new Date() + + sendEvent({ + type: 'execution:started', + timestamp: startTime.toISOString(), + executionId, + workflowId, + data: { startTime: startTime.toISOString() }, + }) + + const result = await executeWorkflowCore({ + snapshot, + loggingSession, + abortSignal: abortController.signal, + runFromBlock: { + startBlockId, + sourceSnapshot: sourceSnapshot as SerializableExecutionState, + }, + callbacks: { onBlockStart, onBlockComplete, onStream }, + }) + + if (result.status === 'cancelled') { + sendEvent({ + type: 'execution:cancelled', + timestamp: new Date().toISOString(), + executionId, + workflowId, + data: { duration: result.metadata?.duration || 0 }, + }) + } else { + sendEvent({ + type: 'execution:completed', + timestamp: new Date().toISOString(), + executionId, + workflowId, + data: { + success: result.success, + output: result.output, + duration: result.metadata?.duration || 0, + startTime: result.metadata?.startTime || startTime.toISOString(), + endTime: result.metadata?.endTime || new Date().toISOString(), + }, + }) + } + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + logger.error(`[${requestId}] Run-from-block execution failed: ${errorMessage}`) + + const executionResult = hasExecutionResult(error) ? error.executionResult : undefined + + sendEvent({ + type: 'execution:error', + timestamp: new Date().toISOString(), + executionId, + workflowId, + data: { + error: executionResult?.error || errorMessage, + duration: executionResult?.metadata?.duration || 0, + }, + }) + } finally { + if (!isStreamClosed) { + try { + controller.enqueue(new TextEncoder().encode('data: [DONE]\n\n')) + controller.close() + } catch {} + } + } + }, + cancel() { + isStreamClosed = true + abortController.abort() + markExecutionCancelled(executionId).catch(() => {}) + }, + }) + + return new NextResponse(stream, { + headers: { ...SSE_HEADERS, 'X-Execution-Id': executionId }, + }) + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + logger.error(`[${requestId}] Failed to start run-from-block execution:`, error) + return NextResponse.json( + { error: errorMessage || 'Failed to start execution' }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index 856a1a3c94..47f81ef122 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -53,6 +53,7 @@ const ExecuteWorkflowSchema = z.object({ parallels: z.record(z.any()).optional(), }) .optional(), + stopAfterBlockId: z.string().optional(), }) export const runtime = 'nodejs' @@ -222,6 +223,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: includeFileBase64, base64MaxBytes, workflowStateOverride, + stopAfterBlockId, } = validation.data // For API key and internal JWT auth, the entire body is the input (except for our control fields) @@ -237,6 +239,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: includeFileBase64, base64MaxBytes, workflowStateOverride, + stopAfterBlockId: _stopAfterBlockId, workflowId: _workflowId, // Also exclude workflowId used for internal JWT auth ...rest } = body @@ -434,6 +437,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: loggingSession, includeFileBase64, base64MaxBytes, + stopAfterBlockId, }) const outputWithBase64 = includeFileBase64 @@ -722,6 +726,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: abortSignal: abortController.signal, includeFileBase64, base64MaxBytes, + stopAfterBlockId, }) if (result.status === 'paused') { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx index 42d2c3e84e..aa65c7b30e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx @@ -1,11 +1,13 @@ import { memo, useCallback } from 'react' -import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, LogOut } from 'lucide-react' +import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, LogOut, Play } from 'lucide-react' import { Button, Copy, Tooltip, Trash2 } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks' import { validateTriggerPaste } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils' import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' +import { useExecutionStore } from '@/stores/execution' import { useNotificationStore } from '@/stores/notifications' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' @@ -49,6 +51,7 @@ export const ActionBar = memo( collaborativeBatchToggleBlockHandles, } = useCollaborativeWorkflow() const { setPendingSelection } = useWorkflowRegistry() + const { handleRunFromBlock } = useWorkflowExecution() const addNotification = useNotificationStore((s) => s.addNotification) @@ -97,12 +100,39 @@ export const ActionBar = memo( ) ) + const { activeWorkflowId } = useWorkflowRegistry() + const { isExecuting, getLastExecutionSnapshot } = useExecutionStore() const userPermissions = useUserPermissionsContext() + const edges = useWorkflowStore((state) => state.edges) const isStartBlock = isInputDefinitionTrigger(blockType) const isResponseBlock = blockType === 'response' const isNoteBlock = blockType === 'note' const isSubflowBlock = blockType === 'loop' || blockType === 'parallel' + const isInsideSubflow = parentId && (parentType === 'loop' || parentType === 'parallel') + + const snapshot = activeWorkflowId ? getLastExecutionSnapshot(activeWorkflowId) : null + const incomingEdges = edges.filter((edge) => edge.target === blockId) + const isTriggerBlock = incomingEdges.length === 0 + + // Check if each source block is either executed OR is a trigger block (triggers don't need prior execution) + const isSourceSatisfied = (sourceId: string) => { + if (snapshot?.executedBlocks.includes(sourceId)) return true + // Check if source is a trigger (has no incoming edges itself) + const sourceIncomingEdges = edges.filter((edge) => edge.target === sourceId) + return sourceIncomingEdges.length === 0 + } + + // Non-trigger blocks need a snapshot to exist (so upstream outputs are available) + const dependenciesSatisfied = + isTriggerBlock || (snapshot && incomingEdges.every((edge) => isSourceSatisfied(edge.source))) + const canRunFromBlock = + dependenciesSatisfied && !isNoteBlock && !isInsideSubflow && !isExecuting + + const handleRunFromBlockClick = useCallback(() => { + if (!activeWorkflowId || !canRunFromBlock) return + handleRunFromBlock(blockId, activeWorkflowId) + }, [blockId, activeWorkflowId, canRunFromBlock, handleRunFromBlock]) /** * Get appropriate tooltip message based on disabled state @@ -128,30 +158,35 @@ export const ActionBar = memo( 'dark:border-transparent dark:bg-[var(--surface-4)]' )} > - {!isNoteBlock && ( + {!isNoteBlock && !isInsideSubflow && ( - {getTooltipMessage(isEnabled ? 'Disable Block' : 'Enable Block')} + {(() => { + if (disabled) return getTooltipMessage('Run from block') + if (isExecuting) return 'Execution in progress' + if (!dependenciesSatisfied) return 'Run upstream blocks first' + return 'Run from block' + })()} )} - {isSubflowBlock && ( + {!isNoteBlock && ( Version Description + + +
+

+ {currentDescription ? 'Edit the' : 'Add a'} description for{' '} + {versionName} +

+ +
+