diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index a79347de310..a269e063539 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -103,6 +103,7 @@ import { isUntitledName, uniqueMarkdownName, } from '@/app/workspace/[workspaceId]/files/untitled-title' +import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' import { usePinItem, usePinnedIds, useUnpinItem } from '@/hooks/queries/pinned-items' @@ -1681,6 +1682,16 @@ export function Files() { fileInputRef.current?.click() }, [canEdit, uploading]) + useRegisterGlobalCommands(() => [ + { id: 'files-upload', handler: () => handleUploadClick() }, + { id: 'files-new-file', handler: () => void handleCreateFile() }, + { id: 'files-new-folder', handler: () => void handleCreateFolder() }, + { id: 'file-download', handler: () => handleDownloadSelected() }, + { id: 'file-rename', handler: () => handleStartHeaderRename() }, + { id: 'file-share', handler: () => handleShareSelected() }, + { id: 'file-delete', handler: () => handleDeleteSelected() }, + ]) + const searchConfig: SearchConfig = { value: urlSearchTerm, onChange: setSearchTerm, diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx index 651f69c4074..3d71ef5e63b 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx @@ -77,6 +77,7 @@ import { pageUrlKeys, } from '@/app/workspace/[workspaceId]/knowledge/[id]/search-params' import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components' +import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' @@ -707,6 +708,14 @@ export function KnowledgeBase({ setShowAddDocumentsModal(true) } + useRegisterGlobalCommands(() => [ + { id: 'knowledge-base-new-documents', handler: () => setShowAddDocumentsModal(true) }, + { id: 'knowledge-base-new-connector', handler: () => setShowAddConnectorModal(true) }, + { id: 'knowledge-base-rename', handler: () => kbRename.startRename(id, knowledgeBaseName) }, + { id: 'knowledge-base-tags', handler: () => setShowTagsModal(true) }, + { id: 'knowledge-base-delete', handler: () => setShowDeleteDialog(true) }, + ]) + /** * Handles bulk enabling of selected documents */ diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx index 4be2d30d5a0..e01bee4bd83 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx @@ -59,6 +59,7 @@ import { knowledgeUrlKeys, } from '@/app/workspace/[workspaceId]/knowledge/search-params' import { filterKnowledgeBases } from '@/app/workspace/[workspaceId]/knowledge/utils/filter' +import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' @@ -726,6 +727,11 @@ export function Knowledge() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [workspaceId]) + useRegisterGlobalCommands(() => [ + { id: 'knowledge-new-base', handler: () => handleOpenCreateModal() }, + { id: 'knowledge-new-folder', handler: () => void handleCreateFolder() }, + ]) + const handleRenameFolder = useCallback(() => { const folder = activeFolderRef.current if (!folder) return diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index 1406ef06c65..17cc80be70c 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -69,6 +69,7 @@ import { logSortParams, } from '@/app/workspace/[workspaceId]/logs/search-params' import type { Suggestion } from '@/app/workspace/[workspaceId]/logs/types' +import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { getBlock } from '@/blocks/registry' import { useFolderMap, useFolders } from '@/hooks/queries/folders' @@ -698,6 +699,13 @@ export default function Logs() { debouncedSearchQuery, ]) + useRegisterGlobalCommands(() => [ + { id: 'logs-refresh', handler: () => handleRefresh() }, + { id: 'logs-export', handler: () => void handleExport() }, + { id: 'logs-show-dashboard', handler: () => setViewMode('dashboard') }, + { id: 'logs-show-logs', handler: () => setViewMode('logs') }, + ]) + const loadMoreLogs = useCallback(() => { const { isFetching, hasNextPage, fetchNextPage } = logsQueryRef.current if (!isFetching && hasNextPage) { diff --git a/apps/sim/app/workspace/[workspaceId]/providers/global-commands-provider.tsx b/apps/sim/app/workspace/[workspaceId]/providers/global-commands-provider.tsx index ac154f11241..c234881e0a0 100644 --- a/apps/sim/app/workspace/[workspaceId]/providers/global-commands-provider.tsx +++ b/apps/sim/app/workspace/[workspaceId]/providers/global-commands-provider.tsx @@ -27,14 +27,15 @@ export interface ParsedShortcut { export interface GlobalCommand { id?: string - shortcut: string + /** Keyboard binding. Omit for palette-only commands invoked by id. */ + shortcut?: string allowInEditable?: boolean handler: (event: KeyboardEvent) => void } interface RegistryCommand extends GlobalCommand { id: string - parsed: ParsedShortcut + parsed: ParsedShortcut | null } interface GlobalCommandsContextValue { @@ -130,7 +131,7 @@ export function GlobalCommandsProvider({ children }: { children: ReactNode }) { const createdIds: string[] = [] for (const cmd of commands) { const id = cmd.id ?? generateId() - const parsed = parseShortcut(cmd.shortcut) + const parsed = cmd.shortcut ? parseShortcut(cmd.shortcut) : null registryRef.current.set(id, { ...cmd, id, @@ -152,6 +153,7 @@ export function GlobalCommandsProvider({ children }: { children: ReactNode }) { if (e.isComposing) return for (const [, cmd] of registryRef.current) { + if (!cmd.parsed) continue if (!cmd.allowInEditable && isEditableElement(document.activeElement)) continue if (matchesShortcut(e, cmd.parsed)) { diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx index 026bc88cf55..e97eb48963b 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx @@ -36,6 +36,7 @@ import { } from '@/app/workspace/[workspaceId]/components/folders' import { PresenceAvatars } from '@/app/workspace/[workspaceId]/components/presence/presence-avatars' import { LogDetails } from '@/app/workspace/[workspaceId]/logs/components' +import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { ImportCsvDialog } from '@/app/workspace/[workspaceId]/tables/components/import-csv-dialog' import { ImportProgressMenu } from '@/app/workspace/[workspaceId]/tables/components/import-progress-menu' @@ -1068,6 +1069,34 @@ export function Table({ } }, [tableData, workspaceId]) + useRegisterGlobalCommands(() => [ + { + id: 'table-new-column', + handler: () => { + if (!userPermissions.canEdit) return + if (tableDataRef.current?.locks.schemaLocked) { + showBlockedToast('add-column') + return + } + handleAddColumnOfType('string') + }, + }, + { + id: 'table-export-csv', + handler: () => { + if (!tableDataRef.current?.rowCount) return + void handleExportCsv() + }, + }, + { + id: 'table-import-csv', + handler: () => { + if (!userPermissions.canEdit || tableDataRef.current?.locks.insertLocked) return + onRequestImportCsv() + }, + }, + ]) + const columnOptions = useMemo( () => columns.map((col) => ({ diff --git a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx index 7a95bbd3ae8..531f7143475 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx @@ -46,6 +46,7 @@ import { useFolderNavigation, useFolderRowDragDrop, } from '@/app/workspace/[workspaceId]/components/folders' +import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { ImportCsvDialog, @@ -1033,6 +1034,17 @@ export function Tables() { } }, [workspaceId, folders, currentFolderId, createFolderAsync, setSearchTerm, startFolderRename]) + useRegisterGlobalCommands(() => [ + { id: 'tables-new-table', handler: () => void handleCreateTable() }, + { id: 'tables-new-folder', handler: () => void handleCreateFolder() }, + { + id: 'tables-import-csv', + handler: () => { + if (!uploading) csvInputRef.current?.click() + }, + }, + ]) + const headerActions: ResourceAction[] = useMemo( () => [ { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/connection-block-selector/connection-block-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/connection-block-selector/connection-block-selector.tsx index 157f6f41278..110ede75791 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/connection-block-selector/connection-block-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/connection-block-selector/connection-block-selector.tsx @@ -2,13 +2,17 @@ import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react' import { Button, cn } from '@sim/emcn' -import { Search, X } from '@sim/emcn/icons' +import { X } from '@sim/emcn/icons' import { WorkflowBlockBorder, type WorkflowBorderPort } from '@sim/workflow-renderer' import { Command } from 'cmdk' import { useParams } from 'next/navigation' import { usePostHog } from 'posthog-js/react' import { Handle, type NodeProps, Position } from 'reactflow' import { captureEvent } from '@/lib/posthog/client' +import { + CommandFadedList, + CommandSearch, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome' import { MemoizedCommandItem } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items' import { BlocksGroup, @@ -403,10 +407,11 @@ export function ConnectionBlockSelector({ id, data }: NodeProps
- )} - -
- - -
+ +
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx index 42c2169efb8..fc2ae3461c2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx @@ -1,7 +1,8 @@ 'use client' import { useState } from 'react' -import { Chip, Tooltip } from '@sim/emcn' +import { Chip, Tooltip, toast } from '@sim/emcn' +import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { DeployModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal' import { useChangeDetection, @@ -62,7 +63,7 @@ export function Deploy({ activeWorkflowId, userPermissions, disabled = false }: (!isDeployed && deployReadiness.isBlocked && !deployReadiness.isSyncing) const onDeployClick = async () => { - if (disabled || !canDeploy || !activeWorkflowId) return + if (isRegistryLoading || isDisabled || !activeWorkflowId) return if (isDeploymentSettling) { setIsModalOpen(true) @@ -75,6 +76,21 @@ export function Deploy({ activeWorkflowId, userPermissions, disabled = false }: } } + useRegisterGlobalCommands(() => [ + { + id: 'deploy-workflow', + handler: () => { + /* The palette can't render a disabled state for this action yet, so a + gated invocation reports the same reason the button's tooltip shows. */ + if (isRegistryLoading || isDisabled) { + toast({ message: isRegistryLoading ? 'Workflow is still loading' : getTooltipText() }) + return + } + void onDeployClick() + }, + }, + ]) + const getTooltipText = () => { if (isEmpty) { return 'Cannot deploy an empty workflow' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.test.tsx new file mode 100644 index 00000000000..df755fb3041 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.test.tsx @@ -0,0 +1,118 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { Command } from 'cmdk' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + CommandFadedList, + CommandSearch, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome' + +describe('CommandFadedList', () => { + let container: HTMLDivElement + let root: Root + let originalScrollIntoView: typeof HTMLElement.prototype.scrollIntoView + + beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + } + ) + originalScrollIntoView = HTMLElement.prototype.scrollIntoView + Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { + configurable: true, + value: vi.fn(), + }) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + if (originalScrollIntoView) { + HTMLElement.prototype.scrollIntoView = originalScrollIntoView + } else { + Reflect.deleteProperty(HTMLElement.prototype, 'scrollIntoView') + } + vi.unstubAllGlobals() + }) + + it('fades the palette with the short single mask and the shared search surface', () => { + act(() => { + root.render( + + + + + ) + }) + + const list = container.querySelector('[cmdk-list]') + const search = container.querySelector('[cmdk-input]')?.parentElement + expect(list?.className).toContain('transparent_8%,black_13%,black_97%') + expect(list?.className).not.toContain('scrollbar-track') + expect(search?.className).toContain('var(--bg)') + }) + + it('cycles through palette results with Tab and Shift+Tab', () => { + act(() => { + root.render( + + + + First + Second + + + ) + }) + + const input = container.querySelector('[cmdk-input]') + const selectedResult = () => + container.querySelector('[cmdk-item][aria-selected="true"]')?.textContent + + expect(input).not.toBeNull() + expect(selectedResult()).toBe('First') + + const firstTabEvent = new KeyboardEvent('keydown', { + key: 'Tab', + bubbles: true, + cancelable: true, + }) + act(() => { + input?.focus() + input?.dispatchEvent(firstTabEvent) + }) + expect(selectedResult()).toBe('Second') + expect(firstTabEvent.defaultPrevented).toBe(true) + expect(document.activeElement).toBe(input) + + act(() => { + input?.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }) + ) + }) + expect(selectedResult()).toBe('First') + + act(() => { + input?.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Tab', + shiftKey: true, + bubbles: true, + cancelable: true, + }) + ) + }) + expect(selectedResult()).toBe('Second') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.tsx new file mode 100644 index 00000000000..58308c8c42b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.tsx @@ -0,0 +1,112 @@ +'use client' + +import { + type ComponentPropsWithoutRef, + forwardRef, + type KeyboardEvent, + type ReactNode, +} from 'react' +import { cn } from '@sim/emcn' +import { Search } from '@sim/emcn/icons' +import { Command } from 'cmdk' + +type CommandInputProps = ComponentPropsWithoutRef +type CommandListProps = ComponentPropsWithoutRef + +interface CommandSearchProps extends Omit { + surface: 'canvas' | 'palette' + cycleResultsOnTab?: boolean + /** Trailing slot after the input (e.g. a mode hint). Non-interactive. */ + endAdornment?: ReactNode +} + +interface CommandFadedListProps extends CommandListProps { + fade: 'canvas' | 'palette' +} + +/** + * The fog must repaint its host's exact background or it reads as a tinted + * band under the input: the canvas selector card fills with `--surface-2`, + * while the palette's rows sit on the inner `--bg` panel (the dialog's + * surface-4/5 is only the 3px ring around it). + */ +const SEARCH_SURFACE_CLASSNAME = { + canvas: + 'bg-[linear-gradient(to_bottom,var(--surface-2)_0%,color-mix(in_srgb,var(--surface-2)_88%,transparent)_68%,transparent_100%)]', + palette: + 'bg-[linear-gradient(to_bottom,var(--bg)_0%,color-mix(in_srgb,var(--bg)_88%,transparent)_68%,transparent_100%)]', +} as const + +/** + * The palette hides its scrollbar (`scrollbar-none` at the call site), so it + * fades with one plain mask; its band is kept short — fully masked only under + * the floating input (0–8%), legible by 13%, and a brief 97–100% exit — so + * rows spend less time in the fog than on the canvas surface. + */ +const LIST_FADE_CLASSNAME = { + canvas: + '[-webkit-mask-image:linear-gradient(to_bottom,transparent_0%,transparent_8%,black_18%,black_94%,transparent_100%)] [mask-image:linear-gradient(to_bottom,transparent_0%,transparent_8%,black_18%,black_94%,transparent_100%)]', + palette: + '[-webkit-mask-image:linear-gradient(to_bottom,transparent_0%,transparent_8%,black_13%,black_97%,transparent_100%)] [mask-image:linear-gradient(to_bottom,transparent_0%,transparent_8%,black_13%,black_97%,transparent_100%)]', +} as const + +/** Borderless search field layered over a fading command-result list. */ +export const CommandSearch = forwardRef( + function CommandSearch( + { surface, cycleResultsOnTab = false, endAdornment, onKeyDown, ...props }, + ref + ) { + const handleKeyDown = (event: KeyboardEvent) => { + onKeyDown?.(event) + if (!cycleResultsOnTab || event.defaultPrevented || event.key !== 'Tab') return + + event.preventDefault() + event.currentTarget.dispatchEvent( + new window.KeyboardEvent('keydown', { + key: event.shiftKey ? 'ArrowUp' : 'ArrowDown', + bubbles: true, + cancelable: true, + }) + ) + } + + return ( +
+ + + {endAdornment} +
+ ) + } +) + +CommandSearch.displayName = 'CommandSearch' + +/** Scrollable command list with soft edge fades tuned for each command surface. */ +export const CommandFadedList = forwardRef( + function CommandFadedList({ className, fade, ...props }, ref) { + return ( + + ) + } +) + +CommandFadedList.displayName = 'CommandFadedList' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/index.ts new file mode 100644 index 00000000000..cb4a9a8d1e8 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/index.ts @@ -0,0 +1 @@ +export { CommandFadedList, CommandSearch } from './command-chrome' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.test.tsx new file mode 100644 index 00000000000..9008feacf7f --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.test.tsx @@ -0,0 +1,79 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { Command } from 'cmdk' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MemoizedActionItem } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items' + +interface TestIconProps { + className?: string +} + +function TestIcon({ className }: TestIconProps) { + return +} + +describe('MemoizedActionItem', () => { + let container: HTMLDivElement + let root: Root + let originalScrollIntoView: typeof HTMLElement.prototype.scrollIntoView + + beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + } + ) + originalScrollIntoView = HTMLElement.prototype.scrollIntoView + Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { + configurable: true, + value: vi.fn(), + }) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.unstubAllGlobals() + if (originalScrollIntoView) { + HTMLElement.prototype.scrollIntoView = originalScrollIntoView + } else { + Reflect.deleteProperty(HTMLElement.prototype, 'scrollIntoView') + } + }) + + it('centers the command glyph in a fixed three-slot shortcut hint', () => { + act(() => { + root.render( + + + + + + ) + }) + + const shortcut = container.querySelector('[aria-label="Keyboard shortcut ⌘↵"]') + expect(Array.from(shortcut?.children ?? []).map((slot) => slot.textContent)).toEqual([ + '', + '⌘', + '↵', + ]) + expect(container.querySelector('button[aria-label*="favorites"]')).toBeNull() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx index df0a88afd69..5e53d16e56d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx @@ -6,10 +6,79 @@ import { cn } from '@sim/emcn' import { File, Workflow } from '@sim/emcn/icons' import { WorkflowTypeIcon } from '@sim/workflow-renderer' import { Command } from 'cmdk' +import { HEX_COLOR_REGEX } from '@/lib/branding' import type { CommandItemProps } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils' import { COMMAND_ITEM_CLASSNAME } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils' import { getTileIconColorClass } from '@/blocks/icon-color' +interface ResultMetaProps { + meta?: string +} + +interface ItemMetaProps { + meta: string +} + +function ItemMeta({ meta }: ItemMetaProps) { + return ( + {meta} + ) +} + +interface ItemFolderPathProps { + folderPath: string[] +} + +/** Trailing folder-path receipt whose head segments yield space to the leaf. */ +function ItemFolderPath({ folderPath }: ItemFolderPathProps) { + return ( + + {folderPath.length > 1 && ( + <> + + {folderPath.slice(0, -1).join(' / ')} + + / + + )} + {folderPath[folderPath.length - 1]} + + ) +} + +/** Structural equality for the optional folder-path prop in memo comparators. */ +function sameFolderPath(prev?: string[], next?: string[]): boolean { + return ( + prev === next || + (prev?.length === next?.length && (prev ?? []).every((segment, i) => segment === next?.[i])) + ) +} + +interface ShortcutHintProps { + shortcut: string +} + +function ShortcutHint({ shortcut }: ShortcutHintProps) { + const commandIndex = shortcut.indexOf('⌘') + const slots = + commandIndex === -1 + ? ['', '', shortcut] + : [shortcut.slice(0, commandIndex), '⌘', shortcut.slice(commandIndex + 1)] + + return ( + + {slots.map((slot, index) => ( + + ))} + + ) +} + export const MemoizedCommandItem = memo( function CommandItem({ value, @@ -19,6 +88,8 @@ export const MemoizedCommandItem = memo( showColoredIcon, workflowType, label, + labelPrefix, + meta, }: CommandItemProps) { return ( @@ -39,7 +110,11 @@ export const MemoizedCommandItem = memo( /> )} - {label} + + {labelPrefix && {labelPrefix} } + {label} + + {meta ? : null} ) }, @@ -49,7 +124,9 @@ export const MemoizedCommandItem = memo( prev.bgColor === next.bgColor && prev.showColoredIcon === next.showColoredIcon && prev.workflowType === next.workflowType && - prev.label === next.label + prev.label === next.label && + prev.labelPrefix === next.labelPrefix && + prev.meta === next.meta ) export const MemoizedActionItem = memo( @@ -59,22 +136,19 @@ export const MemoizedActionItem = memo( icon: Icon, name, shortcut, + meta, }: { value: string onSelect: () => void icon: ComponentType<{ className?: string }> name: string shortcut?: string - }) { + } & ResultMetaProps) { return ( {name} - {shortcut && ( - - {shortcut} - - )} + {meta ? : shortcut ? : null} ) }, @@ -82,38 +156,10 @@ export const MemoizedActionItem = memo( prev.value === next.value && prev.icon === next.icon && prev.name === next.name && - prev.shortcut === next.shortcut + prev.shortcut === next.shortcut && + prev.meta === next.meta ) -/** - * Right-aligned folder breadcrumb. All but the last segment collapse first so a - * deep path degrades to the immediate parent rather than truncating the whole - * trail. Renders nothing at the workspace root. - */ -function FolderPathSuffix({ folderPath }: { folderPath?: string[] }) { - if (!folderPath || folderPath.length === 0) return null - return ( - - {folderPath.length > 1 && ( - <> - - {folderPath.slice(0, -1).join(' / ')} - - / - - )} - {folderPath[folderPath.length - 1]} - - ) -} - -/** Element-wise compare so a rebuilt-but-identical path array skips the re-render. */ -function sameFolderPath(a?: string[], b?: string[]): boolean { - if (a === b) return true - if (a?.length !== b?.length) return false - return (a ?? []).every((segment, i) => segment === b?.[i]) -} - export const MemoizedWorkflowItem = memo( function WorkflowItem({ value, @@ -121,13 +167,14 @@ export const MemoizedWorkflowItem = memo( name, folderPath, isCurrent, + meta, }: { value: string onSelect: () => void name: string folderPath?: string[] isCurrent?: boolean - }) { + } & ResultMetaProps) { return (
@@ -137,7 +184,11 @@ export const MemoizedWorkflowItem = memo( {name} {isCurrent && (current)} - + {meta ? ( + + ) : folderPath && folderPath.length > 0 ? ( + + ) : null} ) }, @@ -145,6 +196,7 @@ export const MemoizedWorkflowItem = memo( prev.value === next.value && prev.name === next.name && prev.isCurrent === next.isCurrent && + prev.meta === next.meta && sameFolderPath(prev.folderPath, next.folderPath) ) @@ -154,12 +206,13 @@ export const MemoizedFileItem = memo( onSelect, name, folderPath, + meta, }: { value: string onSelect: () => void name: string folderPath?: string[] - }) { + } & ResultMetaProps) { return (
@@ -168,13 +221,18 @@ export const MemoizedFileItem = memo( {name} - + {meta ? ( + + ) : folderPath && folderPath.length > 0 ? ( + + ) : null} ) }, (prev, next) => prev.value === next.value && prev.name === next.name && + prev.meta === next.meta && sameFolderPath(prev.folderPath, next.folderPath) ) @@ -183,18 +241,20 @@ export const MemoizedTaskItem = memo( value, onSelect, name, + meta, }: { value: string onSelect: () => void name: string - }) { + } & ResultMetaProps) { return ( {name} + {meta && } ) }, - (prev, next) => prev.value === next.value && prev.name === next.name + (prev, next) => prev.value === next.value && prev.name === next.name && prev.meta === next.meta ) export const MemoizedWorkspaceItem = memo( @@ -203,23 +263,55 @@ export const MemoizedWorkspaceItem = memo( onSelect, name, isCurrent, + logoUrl, + color, + meta, }: { value: string onSelect: () => void name: string isCurrent?: boolean - }) { + logoUrl?: string | null + color?: string + } & ResultMetaProps) { + const backgroundColor = color && HEX_COLOR_REGEX.test(color) ? color : 'var(--brand-accent)' + return ( + {logoUrl ? ( + + ) : ( + + )} {name} {isCurrent && (current)} + {meta && } ) }, (prev, next) => - prev.value === next.value && prev.name === next.name && prev.isCurrent === next.isCurrent + prev.value === next.value && + prev.name === next.name && + prev.isCurrent === next.isCurrent && + prev.logoUrl === next.logoUrl && + prev.color === next.color && + prev.meta === next.meta ) export const MemoizedPageItem = memo( @@ -229,22 +321,19 @@ export const MemoizedPageItem = memo( icon: Icon, name, shortcut, + meta, }: { value: string onSelect: () => void icon: ComponentType<{ className?: string }> name: string shortcut?: string - }) { + } & ResultMetaProps) { return ( {name} - {shortcut && ( - - {shortcut} - - )} + {meta ? : shortcut ? : null} ) }, @@ -252,7 +341,8 @@ export const MemoizedPageItem = memo( prev.value === next.value && prev.icon === next.icon && prev.name === next.name && - prev.shortcut === next.shortcut + prev.shortcut === next.shortcut && + prev.meta === next.meta ) export const MemoizedIconItem = memo( @@ -262,20 +352,25 @@ export const MemoizedIconItem = memo( name, icon: Icon, folderPath, + meta, }: { value: string onSelect: () => void name: string icon: ComponentType<{ className?: string }> folderPath?: string[] - }) { + } & ResultMetaProps) { return ( {name} - + {meta ? ( + + ) : folderPath && folderPath.length > 0 ? ( + + ) : null} ) }, @@ -283,5 +378,6 @@ export const MemoizedIconItem = memo( prev.value === next.value && prev.name === next.name && prev.icon === next.icon && + prev.meta === next.meta && sameFolderPath(prev.folderPath, next.folderPath) ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/index.ts index 18f0ffa3025..f865dbe8b77 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/index.ts @@ -1,3 +1,4 @@ +export { CommandFadedList, CommandSearch } from './command-chrome' export { MemoizedCommandItem, MemoizedFileItem, @@ -7,19 +8,4 @@ export { MemoizedWorkflowItem, MemoizedWorkspaceItem, } from './command-items' -export { - BlocksGroup, - ChatsGroup, - ConnectedAccountsGroup, - DocsGroup, - FilesGroup, - IntegrationsGroup, - KnowledgeBasesGroup, - PagesGroup, - TablesGroup, - ToolOpsGroup, - ToolsGroup, - TriggersGroup, - WorkflowsGroup, - WorkspacesGroup, -} from './search-groups' +export { BlocksGroup, SearchEntryGroup, ToolsGroup } from './search-groups' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/index.ts index 151685fd917..e9e7a074e98 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/index.ts @@ -1,17 +1 @@ -export { - ActionsGroup, - BlocksGroup, - ChatsGroup, - ConnectedAccountsGroup, - DocsGroup, - FilesGroup, - IntegrationsGroup, - KnowledgeBasesGroup, - PagesGroup, - TablesGroup, - ToolOpsGroup, - ToolsGroup, - TriggersGroup, - WorkflowsGroup, - WorkspacesGroup, -} from './search-groups' +export { BlocksGroup, SearchEntryGroup, ToolsGroup } from './search-groups' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.test.tsx new file mode 100644 index 00000000000..75a8e76b7ba --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.test.tsx @@ -0,0 +1,156 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { Command } from 'cmdk' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { SearchEntryGroup } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups' +import type { + SearchEntry, + SearchEntryHandlers, + WorkspaceItem, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils' + +function TestIcon() { + return +} + +const handlers: SearchEntryHandlers = { + onSelectAction: vi.fn(), + onSelectConnectedAccount: vi.fn(), + onSelectIntegration: vi.fn(), + onSelectChat: vi.fn(), + onSelectWorkflow: vi.fn(), + onSelectTable: vi.fn(), + onSelectFile: vi.fn(), + onSelectKnowledgeBase: vi.fn(), + onSelectLog: vi.fn(), + onSelectWorkspace: vi.fn(), + onSelectPage: vi.fn(), +} + +const actionEntry: SearchEntry = { + section: 'actions', + score: 100, + item: { + id: 'run-workflow', + name: 'Run workflow', + icon: TestIcon, + context: 'workflow', + run: vi.fn(), + }, +} + +const workspaceItems: WorkspaceItem[] = [ + { + id: 'workspace-acme', + name: 'Acme', + href: '/workspace/workspace-acme/w', + logoUrl: 'https://cdn.example.com/acme.png', + }, + { + id: 'workspace-beta', + name: 'Beta Workspace', + href: '/workspace/workspace-beta/w', + color: '#123456', + }, +] + +const workspaceEntries: SearchEntry[] = workspaceItems.map((item, index) => ({ + section: 'workspaces', + score: 100 - index, + item, +})) + +describe('SearchEntryGroup', () => { + let container: HTMLDivElement + let root: Root + let originalScrollIntoView: typeof HTMLElement.prototype.scrollIntoView + + beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + } + ) + originalScrollIntoView = HTMLElement.prototype.scrollIntoView + Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { + configurable: true, + value: vi.fn(), + }) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.unstubAllGlobals() + if (originalScrollIntoView) { + HTMLElement.prototype.scrollIntoView = originalScrollIntoView + } else { + Reflect.deleteProperty(HTMLElement.prototype, 'scrollIntoView') + } + }) + + it('renders flat search results without passing a null group heading to cmdk', () => { + act(() => { + root.render( + + + + + + ) + }) + + expect(container.textContent).toContain('Run workflow') + expect(container.querySelector('[cmdk-group-heading]')).toBeNull() + expect(container.querySelector('button[aria-label*="favorites"]')).toBeNull() + }) + + it('renders workspace logos and initial fallbacks in workspace results', () => { + act(() => { + root.render( + + + + + + ) + }) + + const logo = container.querySelector('img[data-slot="workspace-icon"]') + const fallback = container.querySelector('span[data-slot="workspace-icon"]') + expect(logo?.src).toBe('https://cdn.example.com/acme.png') + expect(logo?.alt).toBe('') + expect(fallback?.textContent).toBe('B') + expect(fallback?.querySelector('rect')?.getAttribute('fill')).toBe('#123456') + }) + + it('renders workspace icons in the default workspace section', () => { + act(() => { + root.render( + + + + + + ) + }) + + expect(container.querySelector('img[data-slot="workspace-icon"]')).not.toBeNull() + expect(container.querySelector('span[data-slot="workspace-icon"]')?.textContent).toBe('B') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.tsx index d3589afeb77..2ff8c4cdaeb 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.tsx @@ -1,7 +1,8 @@ 'use client' -import type { ComponentType } from 'react' +import type { ReactElement } from 'react' import { memo } from 'react' +import { Library } from '@sim/emcn' import { Database, Table } from '@sim/emcn/icons' import { Command } from 'cmdk' import { @@ -15,46 +16,13 @@ import { MemoizedWorkspaceItem, } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items' import type { - ActionItem, - FileItem, - FolderedItem, - IntegrationSearchItem, - PageItem, - TaskItem, - WorkflowItem, - WorkspaceItem, + SearchEntry, + SearchEntryHandlers, } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils' import { GROUP_HEADING_CLASSNAME } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils' -import type { - SearchBlockItem, - SearchDocItem, - SearchToolOperationItem, -} from '@/stores/modals/search/types' - -export const ActionsGroup = memo(function ActionsGroup({ - items, - onSelect, -}: { - items: ActionItem[] - onSelect: (action: ActionItem) => void -}) { - if (items.length === 0) return null - return ( - - {items.map((action) => ( - onSelect(action)} - icon={action.icon} - name={action.name} - shortcut={action.shortcut} - /> - ))} - - ) -}) +import type { SearchBlockItem } from '@/stores/modals/search/types' +/** Canvas block group, consumed by the connection block selector. */ export const BlocksGroup = memo(function BlocksGroup({ items, onSelect, @@ -83,6 +51,7 @@ export const BlocksGroup = memo(function BlocksGroup({ ) }) +/** Canvas tool group, consumed by the connection block selector. */ export const ToolsGroup = memo(function ToolsGroup({ items, onSelect, @@ -108,262 +77,219 @@ export const ToolsGroup = memo(function ToolsGroup({ ) }) -export const TriggersGroup = memo(function TriggersGroup({ - items, - onSelect, -}: { - items: SearchBlockItem[] - onSelect: (trigger: SearchBlockItem) => void -}) { - if (items.length === 0) return null - return ( - - {items.map((trigger) => ( +interface RenderEntryOptions { + keyPrefix: string +} + +function renderSearchEntry( + entry: SearchEntry, + handlers: SearchEntryHandlers, + options: RenderEntryOptions +): ReactElement { + const key = `${options.keyPrefix}${entry.section}-${entry.item.id}` + + switch (entry.section) { + case 'actions': + return ( + handlers.onSelectAction(entry.item)} + icon={entry.item.icon} + name={entry.item.name} + shortcut={entry.item.shortcut} + /> + ) + case 'blocks': + return ( onSelect(trigger)} - icon={trigger.icon} - bgColor={trigger.bgColor} + key={key} + value={`${entry.item.name} ${key}`} + onSelect={() => handlers.onSelectBlock(entry.item)} + icon={entry.item.icon} + bgColor={entry.item.bgColor} showColoredIcon - label={trigger.name} + workflowType={entry.item.type} + label={entry.item.name} /> - ))} - - ) -}) - -export const ToolOpsGroup = memo(function ToolOpsGroup({ - items, - onSelect, -}: { - items: SearchToolOperationItem[] - onSelect: (op: SearchToolOperationItem) => void -}) { - if (items.length === 0) return null - return ( - - {items.map((op) => ( + ) + case 'tools': + return ( onSelect(op)} - icon={op.icon} - bgColor={op.bgColor} + key={key} + value={`${entry.item.name} ${key}`} + onSelect={() => handlers.onSelectTool(entry.item)} + icon={entry.item.icon} + bgColor={entry.item.bgColor} showColoredIcon - label={op.name} + label={entry.item.name} /> - ))} - - ) -}) - -export const DocsGroup = memo(function DocsGroup({ - items, - onSelect, -}: { - items: SearchDocItem[] - onSelect: (doc: SearchDocItem) => void -}) { - if (items.length === 0) return null - return ( - - {items.map((doc) => ( + ) + case 'triggers': + return ( onSelect(doc)} - icon={doc.icon} - bgColor='#6B7280' + key={key} + value={`${entry.item.name} ${key}`} + onSelect={() => handlers.onSelectTrigger(entry.item)} + icon={entry.item.icon} + bgColor={entry.item.bgColor} showColoredIcon - label={doc.name} + label={entry.item.name} /> - ))} - - ) -}) - -export const WorkflowsGroup = memo(function WorkflowsGroup({ - items, - onSelect, -}: { - items: WorkflowItem[] - onSelect: (workflow: WorkflowItem) => void -}) { - if (items.length === 0) return null - return ( - - {items.map((workflow) => ( - onSelect(workflow)} - name={workflow.name} - folderPath={workflow.folderPath} - isCurrent={workflow.isCurrent} + ) + case 'toolOperations': + return ( + handlers.onSelectToolOperation(entry.item)} + icon={entry.item.icon} + bgColor={entry.item.bgColor} + showColoredIcon + labelPrefix={entry.item.serviceName} + label={entry.item.name} /> - ))} - - ) -}) - -export const ChatsGroup = memo(function ChatsGroup({ - items, - onSelect, -}: { - items: TaskItem[] - onSelect: (task: TaskItem) => void -}) { - if (items.length === 0) return null - return ( - - {items.map((task) => ( + ) + case 'connectedAccounts': + return ( + handlers.onSelectConnectedAccount(entry.item)} + icon={entry.item.icon} + bgColor={entry.item.bgColor} + showColoredIcon + label={entry.item.name} + /> + ) + case 'integrations': + return ( + handlers.onSelectIntegration(entry.item)} + icon={entry.item.icon} + bgColor={entry.item.bgColor} + showColoredIcon + label={entry.item.name} + /> + ) + case 'chats': + return ( onSelect(task)} - name={task.name} + key={key} + value={`${entry.item.name} ${key}`} + onSelect={() => handlers.onSelectChat(entry.item)} + name={entry.item.name} + meta={entry.item.date} /> - ))} - - ) -}) - -export const WorkspacesGroup = memo(function WorkspacesGroup({ - items, - onSelect, -}: { - items: WorkspaceItem[] - onSelect: (workspace: WorkspaceItem) => void -}) { - if (items.length === 0) return null - return ( - - {items.map((workspace) => ( + ) + case 'workflows': + return ( + handlers.onSelectWorkflow(entry.item)} + name={entry.item.name} + folderPath={entry.item.folderPath} + isCurrent={entry.item.isCurrent} + /> + ) + case 'tables': + return ( + handlers.onSelectTable(entry.item)} + name={entry.item.name} + icon={Table} + folderPath={entry.item.folderPath} + /> + ) + case 'files': + return ( + handlers.onSelectFile(entry.item)} + name={entry.item.name} + folderPath={entry.item.folderPath} + /> + ) + case 'knowledgeBases': + return ( + handlers.onSelectKnowledgeBase(entry.item)} + name={entry.item.name} + icon={Database} + folderPath={entry.item.folderPath} + /> + ) + case 'logs': + return ( + handlers.onSelectLog(entry.item)} + name={entry.item.name} + icon={Library} + meta={entry.item.date} + /> + ) + case 'workspaces': + return ( onSelect(workspace)} - name={workspace.name} - isCurrent={workspace.isCurrent} + key={key} + value={`${entry.item.name} ${key}`} + onSelect={() => handlers.onSelectWorkspace(entry.item)} + name={entry.item.name} + isCurrent={entry.item.isCurrent} + logoUrl={entry.item.logoUrl} + color={entry.item.color} /> - ))} - - ) -}) - -export const PagesGroup = memo(function PagesGroup({ - items, - onSelect, -}: { - items: PageItem[] - onSelect: (page: PageItem) => void -}) { - if (items.length === 0) return null - return ( - - {items.map((page) => ( + ) + case 'pages': + return ( onSelect(page)} - icon={page.icon} - name={page.name} - shortcut={page.shortcut} + key={key} + value={`${entry.item.name} ${key}`} + onSelect={() => handlers.onSelectPage(entry.item)} + icon={entry.item.icon} + name={entry.item.name} + shortcut={entry.item.shortcut} /> - ))} - - ) -}) + ) + } +} -export const TablesGroup = createIconGroup('Tables', 'table', Table) -export const KnowledgeBasesGroup = createIconGroup('Knowledge bases', 'knowledge-base', Database) +interface SearchEntryGroupProps { + variant: 'section' | 'results' + heading?: string + entries: SearchEntry[] + handlers: SearchEntryHandlers +} -export const ConnectedAccountsGroup = createColoredIconGroup('Connected', 'connected-account') -export const IntegrationsGroup = createColoredIconGroup('Integrations', 'integration') +/** Renders ordinary and aggregate rows with their existing section chrome. */ +export const SearchEntryGroup = memo(function SearchEntryGroup({ + variant, + heading, + entries, + handlers, +}: SearchEntryGroupProps) { + if (entries.length === 0) return null + + const keyPrefix = variant === 'results' ? 'results-' : '' + const renderedEntries = entries.map((entry) => renderSearchEntry(entry, handlers, { keyPrefix })) + + if (variant === 'results') { + return {renderedEntries} + } -export const FilesGroup = memo(function FilesGroup({ - items, - onSelect, -}: { - items: FileItem[] - onSelect: (file: FileItem) => void -}) { - if (items.length === 0) return null return ( - - {items.map((file) => ( - onSelect(file)} - name={file.name} - folderPath={file.folderPath} - /> - ))} + + {renderedEntries} ) }) - -/** - * Factory for groups that render each item with its own brand icon on a - * brand-colored tile (the same `showColoredIcon` pattern used by - * `BlocksGroup` / `ToolsGroup`). Used for integrations and connected accounts - * where every row has a distinct per-item icon and brand color. - */ -function createColoredIconGroup(heading: string, prefix: string) { - return memo(function ColoredIconGroup({ - items, - onSelect, - }: { - items: IntegrationSearchItem[] - onSelect: (item: IntegrationSearchItem) => void - }) { - if (items.length === 0) return null - return ( - - {items.map((item) => ( - onSelect(item)} - icon={item.icon} - bgColor={item.bgColor} - showColoredIcon - label={item.name} - /> - ))} - - ) - }) -} - -function createIconGroup( - heading: string, - prefix: string, - icon: ComponentType<{ className?: string }> -) { - return memo(function IconGroup({ - items, - onSelect, - }: { - items: FolderedItem[] - onSelect: (item: FolderedItem) => void - }) { - if (items.length === 0) return null - return ( - - {items.map((item) => ( - onSelect(item)} - name={item.name} - icon={icon} - folderPath={item.folderPath} - /> - ))} - - ) - }) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx new file mode 100644 index 00000000000..d10ca918fe4 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx @@ -0,0 +1,721 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage' +import { + MOTHERSHIP_SEND_MESSAGE_EVENT, + type MothershipSendMessageDetail, +} from '@/lib/mothership/events' +import { SearchModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal' + +const { mockPush, mockSearchState } = vi.hoisted(() => ({ + mockPush: vi.fn(), + mockSearchState: { + data: { + blocks: [] as unknown[], + tools: [] as unknown[], + triggers: [] as unknown[], + toolOperations: [] as unknown[], + isInitialized: true, + }, + }, +})) + +vi.mock('next/navigation', () => ({ + useParams: () => ({ workspaceId: 'workspace-1', workflowId: 'workflow-1' }), + useRouter: () => ({ push: mockPush }), +})) + +vi.mock('posthog-js/react', () => ({ + usePostHog: () => ({}), +})) + +vi.mock('@/lib/core/config/env-flags', () => ({ + isChatEnabled: true, +})) + +vi.mock('@/lib/posthog/client', () => ({ + captureEvent: vi.fn(), +})) + +vi.mock('@/app/workspace/[workspaceId]/providers/global-commands-provider', () => ({ + useInvokeGlobalCommand: () => vi.fn(), +})) + +vi.mock('@/lib/workflows/triggers/trigger-utils', () => ({ + hasTriggerCapability: () => false, +})) + +vi.mock('@/stores/modals/search/store', () => ({ + useSearchModalStore: Object.assign( + (selector: (state: typeof mockSearchState) => unknown) => selector(mockSearchState), + { getState: () => mockSearchState } + ), +})) + +vi.mock('@/app/workspace/[workspaceId]/w/components/sidebar/sidebar', () => ({ + SIDEBAR_SCROLL_EVENT: 'sidebar-scroll-to-item', +})) + +vi.mock('@/hooks/use-permission-config', () => ({ + usePermissionConfig: () => ({ + config: { + hideIntegrationsTab: false, + hideTablesTab: false, + hideFilesTab: false, + hideKnowledgeBaseTab: false, + }, + }), +})) + +vi.mock('@/hooks/use-settings-navigation', () => ({ + useSettingsNavigation: () => ({ navigateToSettings: vi.fn() }), +})) + +async function enterSearchQuery(query: string): Promise { + const input = document.querySelector('input[aria-label="Search anything"]') + if (!input) throw new Error('Search input not found') + + await act(async () => { + const valueSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + 'value' + )?.set + valueSetter?.call(input, query) + input.dispatchEvent(new Event('input', { bubbles: true })) + }) +} + +describe('SearchModal', () => { + let container: HTMLDivElement + let root: Root + let originalScrollIntoView: typeof HTMLElement.prototype.scrollIntoView + + beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + localStorage.clear() + mockPush.mockClear() + window.history.replaceState({}, '', '/workspace/workspace-1/w/workflow-1') + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + } + ) + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + callback(0) + return 1 + }) + originalScrollIntoView = HTMLElement.prototype.scrollIntoView + Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { + configurable: true, + value: vi.fn(), + }) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + document.querySelectorAll('[role="dialog"]').forEach((dialog) => dialog.remove()) + if (originalScrollIntoView) { + HTMLElement.prototype.scrollIntoView = originalScrollIntoView + } else { + Reflect.deleteProperty(HTMLElement.prototype, 'scrollIntoView') + } + vi.unstubAllGlobals() + }) + + it('toggles ask mode with Tab and hands the query to Sim on Enter', async () => { + const onOpenChange = vi.fn() + await act(async () => { + root.render() + }) + + await enterSearchQuery('plan our Slack launch week') + const input = document.querySelector('input[aria-label="Search anything"]') + act(() => { + input?.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }) + ) + }) + + const askRow = document.querySelector('[cmdk-item]') + expect(document.querySelectorAll('[cmdk-item]')).toHaveLength(1) + expect(askRow?.textContent).toBe('New Chat: plan our Slack launch week') + expect(askRow?.getAttribute('aria-selected')).toBe('true') + + act(() => { + document + .querySelector('input[aria-label="Ask Sim"]') + ?.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }) + ) + }) + + expect(onOpenChange).toHaveBeenCalledWith(false) + expect(mockPush).toHaveBeenCalledWith('/workspace/workspace-1/home') + expect(MothershipHandoffStorage.consume('workspace-1')).toEqual({ + message: 'plan our Slack launch week', + contexts: [], + }) + }) + + it('returns to search results when Tab is pressed again in ask mode', async () => { + await act(async () => { + root.render( + + ) + }) + + await enterSearchQuery('rainbow') + const input = document.querySelector('input[aria-label="Search anything"]') + act(() => { + input?.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }) + ) + }) + expect(document.querySelector('[cmdk-item]')?.textContent).toBe('New Chat: rainbow') + + act(() => { + document + .querySelector('input[aria-label="Ask Sim"]') + ?.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }) + ) + }) + expect(document.querySelector('[cmdk-item]')?.textContent).toContain('Rainbow workflow') + }) + + it('puts the page itself first for an exact page-name query, then its contents', async () => { + const logs = [ + { + id: 'log-1', + name: 'Billing sync', + href: '/workspace/workspace-1/logs?executionId=e1', + date: 'Aug 8, 1:00 PM', + }, + { + id: 'log-2', + name: 'Onboarding', + href: '/workspace/workspace-1/logs?executionId=e2', + date: 'Aug 8, 2:00 PM', + }, + ] + await act(async () => { + root.render() + }) + + await enterSearchQuery('Logs') + const rows = Array.from(document.querySelectorAll('[cmdk-item]')).map( + (el) => el.textContent ?? '' + ) + expect(rows[0]).toContain('Logs') + expect(rows[1]).toContain('Billing sync') + expect(rows[2]).toContain('Onboarding') + }) + + it('puts Create workflow first for the module-name query, then the workflows', async () => { + const workflows = [ + { id: 'workflow-a', name: 'Alpha', href: '/workspace/workspace-1/w/workflow-a' }, + { id: 'workflow-b', name: 'Beta', href: '/workspace/workspace-1/w/workflow-b' }, + ] + await act(async () => { + root.render( + + ) + }) + + await enterSearchQuery('workflows') + const rows = Array.from(document.querySelectorAll('[cmdk-item]')).map( + (el) => el.textContent ?? '' + ) + expect(rows[0]).toContain('Create workflow') + expect(rows[1]).toContain('Alpha') + expect(rows[2]).toContain('Beta') + }) + + it('shows an empty state when search has no results', async () => { + await act(async () => { + root.render() + }) + + await enterSearchQuery('explain quantum rainbows') + + expect(document.querySelectorAll('[cmdk-item]')).toHaveLength(0) + expect(document.querySelector('[cmdk-empty]')?.textContent).toBe('No results found.') + }) + + it('sends the query directly when the new-chat surface is already mounted', async () => { + window.history.replaceState({}, '', '/workspace/workspace-1/home') + const receivedMessages: string[] = [] + const handleMessage = (event: Event) => { + receivedMessages.push((event as CustomEvent).detail.message) + event.preventDefault() + } + window.addEventListener(MOTHERSHIP_SEND_MESSAGE_EVENT, handleMessage) + + try { + await act(async () => { + root.render() + }) + await enterSearchQuery('summarize this workspace') + act(() => { + document + .querySelector('input[aria-label="Search anything"]') + ?.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }) + ) + }) + + act(() => { + document.querySelector('[cmdk-item]')?.click() + }) + + expect(receivedMessages).toEqual(['summarize this workspace']) + expect(mockPush).not.toHaveBeenCalled() + expect(MothershipHandoffStorage.consume('workspace-1')).toBeNull() + } finally { + window.removeEventListener(MOTHERSHIP_SEND_MESSAGE_EVENT, handleMessage) + } + }) + + it('puts the Start Trigger first when the query is its exact name', async () => { + const Icon = () => null + const original = { ...mockSearchState.data } + mockSearchState.data = { + ...mockSearchState.data, + triggers: [{ id: 'start', name: 'Start', icon: Icon, bgColor: '#111', type: 'start' }], + toolOperations: [ + { + id: 'browser_start_task', + name: 'Start Task', + serviceName: 'Browser', + searchValue: 'browser start-task', + icon: Icon, + bgColor: '#611f69', + blockType: 'browser', + operationId: 'start_task', + }, + ], + } + const workflows = [ + { id: 'workflow-start', name: 'Start', href: '/workspace/workspace-1/w/workflow-start' }, + ] + + try { + await act(async () => { + root.render( + + ) + }) + + await enterSearchQuery('start') + const rows = Array.from(document.querySelectorAll('[cmdk-item]')).map( + (el) => el.textContent ?? '' + ) + expect(rows[0]).toContain('Start Trigger') + expect(rows.some((row) => row.includes('Start Task'))).toBe(true) + } finally { + mockSearchState.data = original + } + }) + + it('puts the workflow verb actions first for their bare-verb queries', async () => { + const Icon = () => null + const original = { ...mockSearchState.data } + mockSearchState.data = { + ...mockSearchState.data, + toolOperations: [ + { + id: 'vercel_deploy', + name: 'Deploy', + serviceName: 'Vercel', + searchValue: 'vercel deploy', + icon: Icon, + bgColor: '#000', + blockType: 'vercel', + operationId: 'deploy', + }, + { + id: 'sheets_copy', + name: 'Copy', + serviceName: 'Sheets', + searchValue: 'sheets copy', + icon: Icon, + bgColor: '#0f9d58', + blockType: 'sheets', + operationId: 'copy', + }, + ], + } + + try { + await act(async () => { + root.render() + }) + + await enterSearchQuery('deploy') + let rows = Array.from(document.querySelectorAll('[cmdk-item]')).map( + (el) => el.textContent ?? '' + ) + expect(rows[0]).toContain('Deploy workflow') + expect(rows.some((row) => row.includes('Vercel'))).toBe(true) + + await enterSearchQuery('copy') + rows = Array.from(document.querySelectorAll('[cmdk-item]')).map( + (el) => el.textContent ?? '' + ) + expect(rows[0]).toContain('Copy workflow link') + expect(rows.some((row) => row.includes('Sheets'))).toBe(true) + } finally { + mockSearchState.data = original + } + }) + + it('browses every section uncapped', async () => { + const Icon = () => null + const workflows = Array.from({ length: 10 }, (_, index) => ({ + id: `workflow-${index}`, + name: `Zeta ${index}`, + href: `/workspace/workspace-1/w/workflow-${index}`, + })) + const integrations = Array.from({ length: 30 }, (_, index) => ({ + id: `catalog-${index}`, + name: `Acme ${index}`, + href: `/workspace/workspace-1/integrations/catalog-${index}`, + icon: Icon, + bgColor: '#111', + })) + + await act(async () => { + root.render( + + ) + }) + + const rows = Array.from(document.querySelectorAll('[cmdk-item]')).map( + (el) => el.textContent ?? '' + ) + expect(rows.filter((row) => /Zeta \d/.test(row))).toHaveLength(10) + expect(rows.filter((row) => /Acme \d/.test(row))).toHaveLength(30) + }) + + it('ranks a matched action above an exact-named entity from another section', async () => { + const workflows = [ + { id: 'workflow-run', name: 'Run', href: '/workspace/workspace-1/w/workflow-run' }, + ] + await act(async () => { + root.render( + + ) + }) + + await enterSearchQuery('run') + const rows = Array.from(document.querySelectorAll('[cmdk-item]')).map( + (el) => el.textContent ?? '' + ) + expect(rows[0]).toContain('Run workflow') + expect(rows.some((row) => row.includes('Run') && !row.includes('Run workflow'))).toBe(true) + }) + + it('orders canvas browse groups as Actions, Sim, building blocks, then the standard tail', async () => { + const Icon = () => null + const block = { id: 'agent', name: 'Agent', icon: Icon, bgColor: '#111', type: 'agent' } + const original = { ...mockSearchState.data } + mockSearchState.data = { + ...mockSearchState.data, + blocks: [block], + triggers: [{ ...block, id: 'schedule', name: 'Schedule', type: 'schedule' }], + tools: [{ ...block, id: 'slack', name: 'Slack', type: 'slack' }], + toolOperations: [ + { + id: 'slack_send_message', + name: 'Send Message', + serviceName: 'Slack', + searchValue: 'slack send-message', + icon: Icon, + bgColor: '#611f69', + blockType: 'slack', + operationId: 'send_message', + }, + ], + } + const workflows = [ + { id: 'workflow-a', name: 'Alpha workflow', href: '/workspace/workspace-1/w/workflow-a' }, + ] + const integrations = [ + { + id: 'slack-int', + name: 'Slack', + href: '/integrations/slack', + icon: Icon, + bgColor: '#611f69', + }, + ] + + try { + await act(async () => { + root.render( + + ) + }) + + const headings = Array.from( + document.querySelectorAll('[cmdk-group-heading]') + ).map((el) => el.textContent) + expect(headings.slice(0, 7)).toEqual([ + 'Actions', + 'Sim', + 'Blocks', + 'Triggers', + 'Tools', + 'Pages', + 'Workflows', + ]) + expect(headings).not.toContain('Tool operations') + expect(headings).not.toContain('Integrations') + expect(headings).not.toContain('Connected Integrations') + } finally { + mockSearchState.data = original + } + }) + + it('hoists a module page’s actions and its entity section directly under the Sim group', async () => { + const tables = [{ id: 'table-1', name: 'Leads', href: '/workspace/workspace-1/tables/table-1' }] + await act(async () => { + root.render( + + ) + }) + + const headings = Array.from(document.querySelectorAll('[cmdk-group-heading]')).map( + (el) => el.textContent + ) + expect(headings.slice(0, 4)).toEqual(['Actions', 'Sim', 'Tables', 'Pages']) + }) + + it('browses the integrations catalog from every page', async () => { + const Icon = () => null + const integrations = [ + { id: 'slack', name: 'Slack', href: '/integrations/slack', icon: Icon, bgColor: '#611f69' }, + ] + await act(async () => { + root.render() + }) + + const headings = Array.from(document.querySelectorAll('[cmdk-group-heading]')).map( + (el) => el.textContent + ) + expect(headings).toContain('Integrations') + + await enterSearchQuery('slack') + const rows = Array.from(document.querySelectorAll('[cmdk-item]')).map( + (el) => el.textContent + ) + expect(rows.some((text) => text?.includes('Slack'))).toBe(true) + }) + + it('re-anchors selection to the first row on every open', async () => { + const workflows = [ + { id: 'workflow-a', name: 'Alpha workflow', href: '/workspace/workspace-1/w/workflow-a' }, + { id: 'workflow-b', name: 'Beta workflow', href: '/workspace/workspace-1/w/workflow-b' }, + ] + await act(async () => { + root.render() + }) + + const input = document.querySelector('input[aria-label="Search anything"]') + act(() => { + input?.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }) + ) + }) + const rows = () => Array.from(document.querySelectorAll('[cmdk-item]')) + expect(rows()[1]?.getAttribute('aria-selected')).toBe('true') + + await act(async () => { + root.render() + }) + await act(async () => { + root.render() + }) + + expect(rows()[0]?.getAttribute('aria-selected')).toBe('true') + expect(rows()[1]?.getAttribute('aria-selected')).toBe('false') + }) + + it('unmounts while closed and reopens with a blank query', async () => { + await act(async () => { + root.render() + }) + await enterSearchQuery('previous search') + + act(() => { + document + .querySelector('input[aria-label="Search anything"]') + ?.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }) + ) + }) + expect(document.querySelector('input[aria-label="Ask Sim"]')).not.toBeNull() + + await act(async () => { + root.render() + }) + expect(document.querySelector('[role="dialog"]')).toBeNull() + expect(document.querySelectorAll('[cmdk-item]')).toHaveLength(0) + + await act(async () => { + root.render() + }) + const input = document.querySelector('input[aria-label="Search anything"]') + expect(input?.value).toBe('') + expect(document.querySelector('input[aria-label="Ask Sim"]')).toBeNull() + }) + + it('hides tool operations in browse but keeps them searchable', async () => { + const Icon = () => null + const original = { ...mockSearchState.data } + mockSearchState.data = { + ...mockSearchState.data, + toolOperations: Array.from({ length: 75 }, (_, index) => ({ + id: `service_operation_${index}`, + name: `Operation ${index}`, + serviceName: 'Service', + searchValue: `service operation-${index}`, + icon: Icon, + bgColor: '#111', + blockType: 'service', + operationId: `operation_${index}`, + })), + } + + try { + await act(async () => { + root.render() + }) + + const browseRows = Array.from(document.querySelectorAll('[cmdk-item]')).filter( + (row) => row.textContent?.includes('Operation') + ) + expect(browseRows).toHaveLength(0) + + await enterSearchQuery('Operation') + expect(document.querySelectorAll('[cmdk-item]')).toHaveLength(50) + + await enterSearchQuery('Operation 74') + const exactRows = Array.from(document.querySelectorAll('[cmdk-item]')) + expect(exactRows.some((row) => row.textContent?.includes('Operation 74'))).toBe(true) + } finally { + mockSearchState.data = original + } + }) + + it('does not offer deploy to workflow users without admin access', async () => { + await act(async () => { + root.render( + + ) + }) + + expect(document.body.textContent).not.toContain('Deploy workflow') + + await act(async () => { + root.render( + + ) + }) + expect(document.body.textContent).toContain('Deploy workflow') + }) + + it('does not duplicate the Trigger suffix', async () => { + const Icon = () => null + const original = { ...mockSearchState.data } + mockSearchState.data = { + ...mockSearchState.data, + triggers: [ + { + id: 'generic_webhook', + name: 'Webhook Trigger', + icon: Icon, + bgColor: '#111', + type: 'generic_webhook', + }, + ], + } + + try { + await act(async () => { + root.render() + }) + expect(document.body.textContent).toContain('Webhook Trigger') + expect(document.body.textContent).not.toContain('Webhook Trigger Trigger') + } finally { + mockSearchState.data = original + } + }) + + it('keeps the palette open when the query handoff cannot be persisted', async () => { + const onOpenChange = vi.fn() + const storeSpy = vi.spyOn(MothershipHandoffStorage, 'store').mockReturnValue(false) + + try { + await act(async () => { + root.render() + }) + await enterSearchQuery('draft a launch plan') + act(() => { + document + .querySelector('input[aria-label="Search anything"]') + ?.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }) + ) + }) + + act(() => { + document.querySelector('[cmdk-item]')?.click() + }) + + expect(onOpenChange).not.toHaveBeenCalled() + expect(mockPush).not.toHaveBeenCalled() + } finally { + storeSpy.mockRestore() + } + }) +}) 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 953952d2698..8c56d3a0590 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,9 +1,19 @@ 'use client' -import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react' +import { + type KeyboardEvent as ReactKeyboardEvent, + useCallback, + useDeferredValue, + useEffect, + useMemo, + useRef, + useState, +} from 'react' import { cn, Library, useNativeSurfaceOcclusionReady } from '@sim/emcn' import { + Columns3, Database, + Download, Duplicate, File, FolderPlus, @@ -12,13 +22,17 @@ import { Home, Integration, Key, + Pencil, Play, Plus, - Search, + RefreshCw, + Rocket, SelectAll, Send, Settings, Table, + TagIcon, + Trash, Upload, } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' @@ -28,58 +42,81 @@ import { usePostHog } from 'posthog-js/react' import { createPortal } from 'react-dom' import { supportsAtomicBrowserPanelOcclusion } from '@/lib/browser-agent/transport' import { isChatEnabled } from '@/lib/core/config/env-flags' +import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage' +import { sendMothershipMessage } from '@/lib/mothership/events' import { captureEvent } from '@/lib/posthog/client' +import { toSearchToken } from '@/lib/search/tokens' import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils' import { useInvokeGlobalCommand } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { - CMDK_ITEM_GAP_CLASS, - CMDK_SECTION_GAP_CLASS, -} from '@/app/workspace/[workspaceId]/w/components/sidebar/constants' -import { SIDEBAR_SCROLL_EVENT } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar' -import { usePermissionConfig } from '@/hooks/use-permission-config' -import { useSettingsNavigation } from '@/hooks/use-settings-navigation' -import { useSearchModalStore } from '@/stores/modals/search/store' -import type { - SearchBlockItem, - SearchDocItem, - SearchSection, - SearchToolOperationItem, -} from '@/stores/modals/search/types' -import { - ActionsGroup, - BlocksGroup, - ChatsGroup, - ConnectedAccountsGroup, - DocsGroup, - FilesGroup, - IntegrationsGroup, - KnowledgeBasesGroup, - PagesGroup, - TablesGroup, - ToolOpsGroup, - ToolsGroup, - TriggersGroup, - WorkflowsGroup, - WorkspacesGroup, -} from './components/search-groups' + CommandFadedList, + CommandSearch, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome' +import { MemoizedActionItem } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items' +import { SearchEntryGroup } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups' import type { + ActionGroupLabel, ActionItem, FileItem, IntegrationSearchItem, + LogItem, PageItem, + SearchEntry, + SearchEntryHandlers, SearchModalProps, + SearchSection, TaskItem, WorkflowItem, WorkspaceItem, -} from './utils' -import { filterAndCap, filterAndSort } from './utils' +} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils' +import { + CANVAS_SECTIONS, + getActionGroupLabel, + getGlobalSearchResults, + MAX_RESULTS_PER_GROUP, + PAGE_CONTEXT_HOISTED_SECTION, + PAGE_MATCH_TIER, + SEARCH_SECTIONS, + SECTION_LABELS, + scoreActions, + scoreSectionItems, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils' +import { + CMDK_ITEM_GAP_CLASS, + CMDK_SECTION_GAP_CLASS, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/constants' +import { SIDEBAR_SCROLL_EVENT } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar' +import { usePermissionConfig } from '@/hooks/use-permission-config' +import { useSettingsNavigation } from '@/hooks/use-settings-navigation' +import { useSearchModalStore } from '@/stores/modals/search/store' +import type { SearchBlockItem, SearchToolOperationItem } from '@/stores/modals/search/types' const logger = createLogger('SearchModal') +/** + * Global row budget for the browse (empty-query) list, applied cumulatively in + * section order. Individual sections are never capped in browse — the budget + * exists purely to bound render cost when the combined lists are huge. + * Currently disabled (Infinity); set a finite number to re-enable the bound. + */ +export const MAX_BROWSE_RESULTS = Number.POSITIVE_INFINITY +const MAX_SEARCH_RESULTS = 50 + +export type { SearchModalProps } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils' + +type SearchModalContentProps = Omit + +export function SearchModal({ open, ...props }: SearchModalProps) { + const [mounted, setMounted] = useState(false) + + useEffect(() => { + setMounted(true) + }, []) -export type { SearchModalProps } from './utils' + if (!mounted || !open) return null + return +} -export function SearchModal({ - open, +function SearchModalContent({ onOpenChange, workflows = [], workspaces = [], @@ -87,27 +124,25 @@ export function SearchModal({ tables = [], files = [], knowledgeBases = [], + logs = [], integrations = [], connectedAccounts = [], - isOnWorkflowPage = false, - isOnIntegrationsPage = false, + pageContext = null, canEdit = false, + canAdmin = false, onCreateWorkflow, onCreateFolder, onImportWorkflow, -}: SearchModalProps) { +}: SearchModalContentProps) { const params = useParams() const router = useRouter() const workspaceId = params.workspaceId as string const currentWorkflowId = params.workflowId as string | undefined const inputRef = useRef(null) - const [mounted, setMounted] = useState(false) + const listRef = useRef(null) const atomicBrowserOcclusion = supportsAtomicBrowserPanelOcclusion() - const nativeSurfaceReady = useNativeSurfaceOcclusionReady(open, 'modal') - const visuallyOpen = open && nativeSurfaceReady - const [retainNativeSurfaceOcclusion, setRetainNativeSurfaceOcclusion] = useState(open) - const nativeSurfaceOcclusionActive = - open || (atomicBrowserOcclusion && retainNativeSurfaceOcclusion) + const nativeSurfaceReady = useNativeSurfaceOcclusionReady(true, 'modal') + const visuallyOpen = nativeSurfaceReady const { navigateToSettings } = useSettingsNavigation() const { config: permissionConfig } = usePermissionConfig() const invokeCommand = useInvokeGlobalCommand() @@ -120,28 +155,7 @@ export function SearchModal({ const posthogRef = useRef(posthog) posthogRef.current = posthog - useEffect(() => { - setMounted(true) - }, []) - - useEffect(() => { - if (!atomicBrowserOcclusion) return - if (open) { - setRetainNativeSurfaceOcclusion(true) - return - } - // Transition-end normally releases this first. The fallback covers - // reduced-motion/user-agent cases where no transition event is emitted. - const timeout = window.setTimeout(() => setRetainNativeSurfaceOcclusion(false), 200) - return () => window.clearTimeout(timeout) - }, [atomicBrowserOcclusion, open]) - - const { blocks, tools, triggers, toolOperations, docs } = useSearchModalStore( - (state) => state.data - ) - - const sections = useSearchModalStore((state) => state.sections) - const showSection = (key: SearchSection) => !sections || sections.includes(key) + const { blocks, tools, triggers, toolOperations } = useSearchModalStore((state) => state.data) const openHelpModal = useCallback(() => { window.dispatchEvent(new CustomEvent('open-help-modal')) @@ -190,7 +204,7 @@ export function SearchModal({ name: 'Logs', icon: Library, href: `/workspace/${workspaceId}/logs`, - shortcut: '⌘⇧L', + shortcut: '⇧⌘L', }, { id: 'secrets', @@ -229,6 +243,8 @@ export function SearchModal({ */ const actions = useMemo((): ActionItem[] => { const list: ActionItem[] = [] + const invoke = (id: string) => () => invokeCommand(id) + list.push({ id: 'run-workflow', name: 'Run workflow', @@ -236,8 +252,43 @@ export function SearchModal({ icon: Play, shortcut: '⌘↵', context: 'workflow', - run: () => invokeCommand('run-workflow'), + run: invoke('run-workflow'), }) + if (canAdmin) { + list.push({ + id: 'deploy-workflow', + name: 'Deploy workflow', + keywords: 'ship release publish api', + exactQueries: ['deploy'], + icon: Rocket, + context: 'workflow', + run: invoke('deploy-workflow'), + }) + } + list.push( + { + id: 'fit-to-view', + name: 'Fit workflow to view', + keywords: 'zoom center recenter canvas reset', + icon: SelectAll, + shortcut: '⇧⌘F', + context: 'workflow', + run: invoke('fit-to-view'), + }, + { + id: 'copy-workflow-url', + name: 'Copy workflow link', + keywords: 'url share clipboard', + exactQueries: ['copy'], + icon: Duplicate, + context: 'workflow', + run: () => { + navigator.clipboard.writeText(window.location.href).catch((error) => { + logger.error('Failed to copy workflow link to clipboard', { error }) + }) + }, + } + ) if (isChatEnabled) { list.push({ id: 'new-chat', @@ -252,7 +303,8 @@ export function SearchModal({ list.push({ id: 'create-workflow', name: 'Create workflow', - keywords: 'new add build', + keywords: 'new add build workflows', + exactQueries: ['workflows'], icon: Plus, context: 'global', run: onCreateWorkflow, @@ -278,27 +330,6 @@ export function SearchModal({ run: onImportWorkflow, }) } - list.push({ - id: 'fit-to-view', - name: 'Fit workflow to view', - keywords: 'zoom center recenter canvas reset', - icon: SelectAll, - shortcut: '⌘⇧F', - context: 'workflow', - run: () => invokeCommand('fit-to-view'), - }) - list.push({ - id: 'copy-workflow-url', - name: 'Copy workflow link', - keywords: 'url share clipboard', - icon: Duplicate, - context: 'workflow', - run: () => { - navigator.clipboard.writeText(window.location.href).catch((error) => { - logger.error('Failed to copy workflow link to clipboard', { error }) - }) - }, - }) list.push({ id: 'invite-teammates', name: 'Invite teammates', @@ -307,10 +338,240 @@ export function SearchModal({ context: 'global', run: () => navigateToSettings({ section: 'teammates' }), }) + + if (canEdit && pageContext === 'tables') { + list.push( + { + id: 'tables-new-table', + name: 'New table', + keywords: 'create add', + icon: Plus, + context: 'tables', + run: invoke('tables-new-table'), + }, + { + id: 'tables-new-folder', + name: 'New folder', + keywords: 'create add group', + icon: FolderPlus, + context: 'tables', + run: invoke('tables-new-folder'), + }, + { + id: 'tables-import-csv', + name: 'Import CSV', + keywords: 'upload tsv spreadsheet', + icon: Upload, + context: 'tables', + run: invoke('tables-import-csv'), + } + ) + } + if (pageContext === 'tableDetail') { + list.push({ + id: 'table-export-csv', + name: 'Export CSV', + keywords: 'download spreadsheet', + icon: Download, + context: 'tableDetail', + run: invoke('table-export-csv'), + }) + } + if (canEdit && pageContext === 'tableDetail') { + list.push( + { + id: 'table-new-column', + name: 'New column', + keywords: 'create add field', + icon: Columns3, + context: 'tableDetail', + run: invoke('table-new-column'), + }, + { + id: 'table-import-csv', + name: 'Import CSV', + keywords: 'upload tsv spreadsheet', + icon: Upload, + context: 'tableDetail', + run: invoke('table-import-csv'), + } + ) + } + if (canEdit && pageContext === 'files') { + list.push( + { + id: 'files-new-file', + name: 'New file', + keywords: 'create add document markdown', + icon: File, + context: 'files', + run: invoke('files-new-file'), + }, + { + id: 'files-new-folder', + name: 'New folder', + keywords: 'create add group', + icon: FolderPlus, + context: 'files', + run: invoke('files-new-folder'), + }, + { + id: 'files-upload', + name: 'Upload', + keywords: 'add import file', + icon: Upload, + context: 'files', + run: invoke('files-upload'), + } + ) + } + if (pageContext === 'fileDetail') { + list.push({ + id: 'file-download', + name: 'Download', + keywords: 'save export', + icon: Download, + context: 'fileDetail', + run: invoke('file-download'), + }) + if (canEdit) { + list.push( + { + id: 'file-rename', + name: 'Rename', + keywords: 'edit name', + icon: Pencil, + context: 'fileDetail', + run: invoke('file-rename'), + }, + { + id: 'file-share', + name: 'Share', + keywords: 'link send', + icon: Send, + context: 'fileDetail', + run: invoke('file-share'), + }, + { + id: 'file-delete', + name: 'Delete', + keywords: 'remove trash', + icon: Trash, + context: 'fileDetail', + run: invoke('file-delete'), + } + ) + } + } + if (canEdit && pageContext === 'knowledge') { + list.push( + { + id: 'knowledge-new-base', + name: 'New base', + keywords: 'create add knowledge kb', + icon: Plus, + context: 'knowledge', + run: invoke('knowledge-new-base'), + }, + { + id: 'knowledge-new-folder', + name: 'New folder', + keywords: 'create add group', + icon: FolderPlus, + context: 'knowledge', + run: invoke('knowledge-new-folder'), + } + ) + } + if (canEdit && pageContext === 'knowledgeBase') { + list.push( + { + id: 'knowledge-base-new-documents', + name: 'New documents', + keywords: 'add upload document', + icon: Plus, + context: 'knowledgeBase', + run: invoke('knowledge-base-new-documents'), + }, + { + id: 'knowledge-base-new-connector', + name: 'New connector', + keywords: 'add sync source connect', + icon: Integration, + context: 'knowledgeBase', + run: invoke('knowledge-base-new-connector'), + }, + { + id: 'knowledge-base-rename', + name: 'Rename', + keywords: 'edit name', + icon: Pencil, + context: 'knowledgeBase', + run: invoke('knowledge-base-rename'), + }, + { + id: 'knowledge-base-tags', + name: 'Edit tags', + keywords: 'label metadata', + icon: TagIcon, + context: 'knowledgeBase', + run: invoke('knowledge-base-tags'), + }, + { + id: 'knowledge-base-delete', + name: 'Delete', + keywords: 'remove trash', + icon: Trash, + context: 'knowledgeBase', + run: invoke('knowledge-base-delete'), + } + ) + } + if (pageContext === 'logs' || pageContext === 'logsDashboard') { + list.push({ + id: 'logs-refresh', + name: 'Refresh', + keywords: 'reload update', + icon: RefreshCw, + context: pageContext, + run: invoke('logs-refresh'), + }) + if (canEdit) { + list.push({ + id: 'logs-export', + name: 'Export', + keywords: 'download csv', + icon: Download, + context: pageContext, + run: invoke('logs-export'), + }) + } + list.push( + pageContext === 'logs' + ? { + id: 'logs-show-dashboard', + name: 'Switch to Dashboard', + keywords: 'charts stats overview', + icon: Library, + context: 'logs', + run: invoke('logs-show-dashboard'), + } + : { + id: 'logs-show-logs', + name: 'Switch to Logs', + keywords: 'list executions runs', + icon: Library, + context: 'logsDashboard', + run: invoke('logs-show-logs'), + } + ) + } return list }, [ workspaceId, canEdit, + canAdmin, + pageContext, onCreateWorkflow, onCreateFolder, onImportWorkflow, @@ -319,46 +580,49 @@ export function SearchModal({ ]) const [search, setSearch] = useState('') - const [prevOpen, setPrevOpen] = useState(open) - if (open !== prevOpen) { - setPrevOpen(open) - if (open) setSearch('') - } + /** + * Ranking runs against the deferred query: the full cross-section re-rank + * (1000+ rows on the canvas) would otherwise block every keystroke's + * commit. Handlers keep reading the live value via `searchRef`. + */ + const deferredSearch = useDeferredValue(search) + /** Tab-toggled ask mode: Enter hands the typed query to Sim as a new chat. */ + const [askMode, setAskMode] = useState(false) + const searchRef = useRef(search) + searchRef.current = search /** - * Focus only once the dialog is actually visible: `.focus()` is a no-op while - * the surface still carries `invisible`, and nothing re-focuses afterwards. + * Focus once the dialog is actually visible: under atomic browser-panel + * occlusion `autoFocus` is suppressed, and `.focus()` is a no-op while the + * surface still carries `invisible`. */ useEffect(() => { - if (!visuallyOpen || !inputRef.current) return - const nativeInputValueSetter = Object.getOwnPropertyDescriptor( - window.HTMLInputElement.prototype, - 'value' - )?.set - if (nativeInputValueSetter) { - nativeInputValueSetter.call(inputRef.current, '') - inputRef.current.dispatchEvent(new Event('input', { bubbles: true })) - } - inputRef.current.focus() + if (!visuallyOpen) return + inputRef.current?.focus() }, [visuallyOpen]) - const deferredSearch = useDeferredValue(search) - const deferredSearchRef = useRef(deferredSearch) - deferredSearchRef.current = deferredSearch - const handleSearchChange = useCallback((value: string) => { setSearch(value) requestAnimationFrame(() => { - const list = document.querySelector('[cmdk-list]') - if (list) { - list.scrollTop = 0 - } + if (listRef.current) listRef.current.scrollTop = 0 }) }, []) - useEffect(() => { - if (!open) return + /** + * Tab flips between searching and asking Sim, keeping the typed text. On the + * way back the ask row unmounts while cmdk still remembers it as selected, + * so Home re-anchors the selection once the result rows are back. + */ + const handleSearchKeyDown = useCallback((event: ReactKeyboardEvent) => { + if (event.key !== 'Tab' || !isChatEnabled) return + event.preventDefault() + setAskMode((mode) => !mode) + requestAnimationFrame(() => { + inputRef.current?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home', bubbles: true })) + }) + }, []) + useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault() @@ -368,7 +632,7 @@ export function SearchModal({ document.addEventListener('keydown', handleKeyDown) return () => document.removeEventListener('keydown', handleKeyDown) - }, [open]) + }, []) const handleBlockSelect = useCallback( (block: SearchBlockItem, type: 'block' | 'trigger' | 'tool') => { @@ -376,16 +640,12 @@ export function SearchModal({ type === 'trigger' && block.config ? hasTriggerCapability(block.config) : false window.dispatchEvent( new CustomEvent('add-block-from-toolbar', { - detail: { - type: block.type, - enableTriggerMode, - pendingConnect: useSearchModalStore.getState().pendingConnect, - }, + detail: { type: block.type, enableTriggerMode }, }) ) captureEvent(posthogRef.current, 'search_result_selected', { result_type: type, - query_length: deferredSearchRef.current.length, + query_length: searchRef.current.length, workspace_id: workspaceId, }) onOpenChangeRef.current(false) @@ -397,16 +657,12 @@ export function SearchModal({ (op: SearchToolOperationItem) => { window.dispatchEvent( new CustomEvent('add-block-from-toolbar', { - detail: { - type: op.blockType, - presetOperation: op.operationId, - pendingConnect: useSearchModalStore.getState().pendingConnect, - }, + detail: { type: op.blockType, presetOperation: op.operationId }, }) ) captureEvent(posthogRef.current, 'search_result_selected', { result_type: 'tool_operation', - query_length: deferredSearchRef.current.length, + query_length: searchRef.current.length, workspace_id: workspaceId, }) onOpenChangeRef.current(false) @@ -414,6 +670,21 @@ export function SearchModal({ [workspaceId] ) + const handleBlockSelectAsBlock = useCallback( + (block: SearchBlockItem) => handleBlockSelect(block, 'block'), + [handleBlockSelect] + ) + + const handleBlockSelectAsTool = useCallback( + (tool: SearchBlockItem) => handleBlockSelect(tool, 'tool'), + [handleBlockSelect] + ) + + const handleBlockSelectAsTrigger = useCallback( + (trigger: SearchBlockItem) => handleBlockSelect(trigger, 'trigger'), + [handleBlockSelect] + ) + const handleWorkflowSelect = useCallback( (workflow: WorkflowItem) => { if (!workflow.isCurrent && workflow.href) { @@ -424,7 +695,7 @@ export function SearchModal({ } captureEvent(posthogRef.current, 'search_result_selected', { result_type: 'workflow', - query_length: deferredSearchRef.current.length, + query_length: searchRef.current.length, workspace_id: workspaceId, }) onOpenChangeRef.current(false) @@ -439,7 +710,7 @@ export function SearchModal({ } captureEvent(posthogRef.current, 'search_result_selected', { result_type: 'workspace', - query_length: deferredSearchRef.current.length, + query_length: searchRef.current.length, workspace_id: workspaceId, }) onOpenChangeRef.current(false) @@ -452,7 +723,7 @@ export function SearchModal({ routerRef.current.push(chat.href) captureEvent(posthogRef.current, 'search_result_selected', { result_type: 'task', - query_length: deferredSearchRef.current.length, + query_length: searchRef.current.length, workspace_id: workspaceId, }) onOpenChangeRef.current(false) @@ -465,7 +736,7 @@ export function SearchModal({ routerRef.current.push(item.href) captureEvent(posthogRef.current, 'search_result_selected', { result_type: 'table', - query_length: deferredSearchRef.current.length, + query_length: searchRef.current.length, workspace_id: workspaceId, }) onOpenChangeRef.current(false) @@ -478,7 +749,7 @@ export function SearchModal({ routerRef.current.push(item.href) captureEvent(posthogRef.current, 'search_result_selected', { result_type: 'file', - query_length: deferredSearchRef.current.length, + query_length: searchRef.current.length, workspace_id: workspaceId, }) onOpenChangeRef.current(false) @@ -491,7 +762,7 @@ export function SearchModal({ routerRef.current.push(item.href) captureEvent(posthogRef.current, 'search_result_selected', { result_type: 'knowledge_base', - query_length: deferredSearchRef.current.length, + query_length: searchRef.current.length, workspace_id: workspaceId, }) onOpenChangeRef.current(false) @@ -512,7 +783,7 @@ export function SearchModal({ } captureEvent(posthogRef.current, 'search_result_selected', { result_type: 'page', - query_length: deferredSearchRef.current.length, + query_length: searchRef.current.length, workspace_id: workspaceId, }) onOpenChangeRef.current(false) @@ -520,12 +791,12 @@ export function SearchModal({ [workspaceId] ) - const handleDocSelect = useCallback( - (doc: SearchDocItem) => { - window.open(doc.href, '_blank', 'noopener,noreferrer') + const handleLogSelect = useCallback( + (item: LogItem) => { + routerRef.current.push(item.href) captureEvent(posthogRef.current, 'search_result_selected', { - result_type: 'docs', - query_length: deferredSearchRef.current.length, + result_type: 'log', + query_length: searchRef.current.length, workspace_id: workspaceId, }) onOpenChangeRef.current(false) @@ -538,7 +809,7 @@ export function SearchModal({ routerRef.current.push(item.href) captureEvent(posthogRef.current, 'search_result_selected', { result_type: 'connected_account', - query_length: deferredSearchRef.current.length, + query_length: searchRef.current.length, workspace_id: workspaceId, }) onOpenChangeRef.current(false) @@ -551,7 +822,7 @@ export function SearchModal({ routerRef.current.push(item.href) captureEvent(posthogRef.current, 'search_result_selected', { result_type: 'integration', - query_length: deferredSearchRef.current.length, + query_length: searchRef.current.length, workspace_id: workspaceId, }) onOpenChangeRef.current(false) @@ -566,178 +837,356 @@ export function SearchModal({ captureEvent(posthogRef.current, 'search_result_selected', { result_type: 'action', action_id: item.id, - query_length: deferredSearchRef.current.length, + query_length: searchRef.current.length, workspace_id: workspaceId, }) }, [workspaceId] ) - const handleBlockSelectAsBlock = useCallback( - (block: SearchBlockItem) => handleBlockSelect(block, 'block'), - [handleBlockSelect] - ) + const handleNewChatFromQuery = useCallback(() => { + const query = searchRef.current.trim() + if (!query) return - const handleBlockSelectAsTool = useCallback( - (tool: SearchBlockItem) => handleBlockSelect(tool, 'tool'), - [handleBlockSelect] - ) + const homeHref = `/workspace/${workspaceId}/home` + const sentToMountedHome = window.location.pathname === homeHref && sendMothershipMessage(query) - const handleBlockSelectAsTrigger = useCallback( - (trigger: SearchBlockItem) => handleBlockSelect(trigger, 'trigger'), - [handleBlockSelect] - ) + if (!sentToMountedHome) { + /* One-shot auto-send handoff: Home's mount consumer sends it on arrival, + so both routes deliver the raw query identically. use-chat's queued + send dispatch now survives the mount-settling effect cycle that used + to silently abort programmatic sends (the old reason this was a + prefill). */ + if (!MothershipHandoffStorage.store({ message: query }, workspaceId)) { + logger.warn('Failed to persist command palette query for a new chat', { + workspaceId, + }) + return + } + routerRef.current.push(homeHref) + } + + onOpenChangeRef.current(false) + captureEvent(posthogRef.current, 'search_result_selected', { + result_type: 'action', + action_id: 'new-chat-from-query', + query_length: query.length, + workspace_id: workspaceId, + }) + }, [workspaceId]) + + /** Enter in ask mode: hand the query to Sim, or just open a new chat when empty. */ + const handleAskSim = useCallback(() => { + if (searchRef.current.trim()) { + handleNewChatFromQuery() + return + } + routerRef.current.push(`/workspace/${workspaceId}/home`) + onOpenChangeRef.current(false) + captureEvent(posthogRef.current, 'search_result_selected', { + result_type: 'action', + action_id: 'new-chat', + query_length: 0, + workspace_id: workspaceId, + }) + }, [workspaceId, handleNewChatFromQuery]) const handleOverlayClick = useCallback(() => { onOpenChangeRef.current(false) }, []) - const filteredActions = useMemo(() => { + const onCanvas = pageContext === 'workflow' + const actionsByGroup = useMemo(() => { const available = actions.filter( - (a) => - a.context === 'global' || - (a.context === 'workflow' && isOnWorkflowPage) || - (a.context === 'integrations' && isOnIntegrationsPage) - ) - return filterAndSort(available, (a) => `${a.name} ${a.keywords ?? ''}`, deferredSearch) - }, [actions, isOnWorkflowPage, isOnIntegrationsPage, deferredSearch]) - - /** - * Blocks and tools rank by name first, with `searchValue` (type + option - * labels) as a lower-tier fallback, so an exact name match wins while a block - * stays findable by an option label. - */ - const filteredBlocks = useMemo(() => { - if (!isOnWorkflowPage) return [] - // A custom block is hidden on its own source workflow's canvas — placing it - // there recurses (same exclusion as the toolbar). - return filterAndCap( - blocks.filter((b) => !b.sourceWorkflowId || b.sourceWorkflowId !== currentWorkflowId), - (b) => b.name, - deferredSearch, - (b) => b.searchValue + (action) => action.context === 'global' || action.context === pageContext ) - }, [isOnWorkflowPage, blocks, deferredSearch, currentWorkflowId]) - - const filteredTools = useMemo(() => { - if (!isOnWorkflowPage) return [] - return filterAndCap( - tools.filter((t) => !t.sourceWorkflowId || t.sourceWorkflowId !== currentWorkflowId), - (t) => t.name, - deferredSearch, - (t) => t.searchValue - ) - }, [isOnWorkflowPage, tools, deferredSearch, currentWorkflowId]) - - const filteredTriggers = useMemo(() => { - if (!isOnWorkflowPage) return [] - return filterAndCap(triggers, (t) => `${t.name} ${t.id}`, deferredSearch) - }, [isOnWorkflowPage, triggers, deferredSearch]) - - const filteredToolOps = useMemo(() => { - if (!isOnWorkflowPage) return [] - return filterAndCap( - toolOperations, - (op) => op.name, - deferredSearch, - (op) => op.searchValue - ) - }, [isOnWorkflowPage, toolOperations, deferredSearch]) - - const filteredDocs = useMemo(() => { - if (!isOnWorkflowPage) return [] - return filterAndCap(docs, (d) => `${d.name} docs documentation`, deferredSearch) - }, [isOnWorkflowPage, docs, deferredSearch]) - - const filteredTables = useMemo( + return { + page: pageContext + ? available.filter((action) => getActionGroupLabel(action) === 'Actions') + : [], + sim: available.filter((action) => getActionGroupLabel(action) === 'Sim'), + } + }, [actions, pageContext]) + const availableBlocks = useMemo( () => - filterAndCap( - tables, - (t) => t.name, - deferredSearch, - (t) => t.folderPath?.join(' ') - ), - [tables, deferredSearch] + onCanvas + ? blocks.filter( + (block) => !block.sourceWorkflowId || block.sourceWorkflowId !== currentWorkflowId + ) + : [], + [onCanvas, blocks, currentWorkflowId] ) - const filteredFiles = useMemo( + const availableTools = useMemo( () => - filterAndCap( - files, - (f) => f.name, - deferredSearch, - (f) => f.folderPath?.join(' ') - ), - [files, deferredSearch] + onCanvas + ? tools.filter( + (tool) => !tool.sourceWorkflowId || tool.sourceWorkflowId !== currentWorkflowId + ) + : [], + [onCanvas, tools, currentWorkflowId] ) - const filteredKnowledgeBases = useMemo( + /** Palette triggers carry a display suffix; `baseName` keeps the true name rankable. */ + const displayTriggers = useMemo( () => - filterAndCap( - knowledgeBases, - (kb) => kb.name, - deferredSearch, - (kb) => kb.folderPath?.join(' ') - ), - [knowledgeBases, deferredSearch] + onCanvas + ? triggers.map((trigger) => ({ + ...trigger, + baseName: trigger.name, + name: trigger.name.endsWith('Trigger') ? trigger.name : `${trigger.name} Trigger`, + })) + : [], + [onCanvas, triggers] ) - const filteredWorkflows = useMemo( - () => - filterAndCap( + const entriesBySection = useMemo((): Record => { + const query = deferredSearch.trim() + const rank = ( + section: SearchSection, + items: T[], + toValue: (item: T) => string, + toExtra?: (item: T) => string | undefined + ) => + query + ? scoreSectionItems(section, items, toValue, deferredSearch, toExtra, MAX_RESULTS_PER_GROUP) + : items.map((item) => ({ item, score: 0 })) + const rankActionGroup = (items: ActionItem[], groupLabel: ActionGroupLabel) => + query + ? scoreActions(items, deferredSearch, MAX_RESULTS_PER_GROUP, groupLabel) + : items.map((item) => ({ item, score: 0 })) + const rankedActions = [ + ...(pageContext ? rankActionGroup(actionsByGroup.page, 'Actions') : []), + ...rankActionGroup(actionsByGroup.sim, 'Sim'), + ] + + return { + actions: rankedActions.map(({ item, score }) => ({ section: 'actions', item, score })), + blocks: rank( + 'blocks', + availableBlocks, + (item) => item.name, + (item) => item.searchValue + ).map(({ item, score }) => ({ section: 'blocks', item, score })), + triggers: rank( + 'triggers', + displayTriggers, + (item) => item.name, + (item) => `${toSearchToken(item.name)} ${item.id}` + ).map(({ item, score }) => ({ + section: 'triggers', + item, + /* The display rename ("Start" → "Start Trigger") costs the exact-name + bonus, so a query that IS the trigger's name ranks it like a page row. */ + score: item.baseName.toLowerCase() === query.toLowerCase() ? PAGE_MATCH_TIER : score, + })), + tools: rank( + 'tools', + availableTools, + (item) => item.name, + (item) => item.searchValue + ).map(({ item, score }) => ({ section: 'tools', item, score })), + /* Tool operations are the one huge list (1000+ rows); browsing them + uncapped makes modal open/close laggy, so they are search-only. */ + toolOperations: rank( + 'toolOperations', + onCanvas && query ? toolOperations : [], + (item) => item.name, + (item) => item.searchValue + ).map(({ item, score }) => ({ section: 'toolOperations', item, score })), + pages: rank('pages', pages, (item) => item.name).map(({ item, score }) => ({ + section: 'pages', + item, + score: item.name.toLowerCase() === query.toLowerCase() ? PAGE_MATCH_TIER : score, + })), + workflows: rank( + 'workflows', workflows, - (w) => w.name, - deferredSearch, - (w) => w.folderPath?.join(' ') + (item) => item.name, + (item) => item.folderPath?.map(toSearchToken).join(' ') + ).map(({ item, score }) => ({ section: 'workflows', item, score })), + workspaces: rank('workspaces', workspaces, (item) => item.name).map(({ item, score }) => ({ + section: 'workspaces', + item, + score, + })), + files: rank( + 'files', + files, + (item) => item.name, + (item) => item.folderPath?.map(toSearchToken).join(' ') + ).map(({ item, score }) => ({ section: 'files', item, score })), + tables: rank( + 'tables', + tables, + (item) => item.name, + (item) => item.folderPath?.map(toSearchToken).join(' ') + ).map(({ item, score }) => ({ section: 'tables', item, score })), + knowledgeBases: rank( + 'knowledgeBases', + knowledgeBases, + (item) => item.name, + (item) => item.folderPath?.map(toSearchToken).join(' ') + ).map(({ item, score }) => ({ section: 'knowledgeBases', item, score })), + logs: rank('logs', logs, (item) => item.name).map(({ item, score }) => ({ + section: 'logs', + item, + score, + })), + connectedAccounts: rank( + 'connectedAccounts', + onCanvas ? [] : connectedAccounts, + (item) => item.name + ).map(({ item, score }) => ({ section: 'connectedAccounts', item, score })), + integrations: rank('integrations', onCanvas ? [] : integrations, (item) => item.name).map( + ({ item, score }) => ({ section: 'integrations', item, score }) ), - [workflows, deferredSearch] - ) - const filteredChats = useMemo( - () => filterAndCap(chats, (t) => t.name, deferredSearch), - [chats, deferredSearch] - ) - const filteredWorkspaces = useMemo( - () => filterAndCap(workspaces, (w) => w.name, deferredSearch), - [workspaces, deferredSearch] - ) - const filteredPages = useMemo( - () => filterAndSort(pages, (p) => p.name, deferredSearch), - [pages, deferredSearch] + chats: rank('chats', chats, (item) => item.name).map(({ item, score }) => ({ + section: 'chats', + item, + score, + })), + } + }, [ + deferredSearch, + actionsByGroup, + pageContext, + onCanvas, + availableBlocks, + availableTools, + displayTriggers, + toolOperations, + integrations, + connectedAccounts, + chats, + workflows, + tables, + files, + knowledgeBases, + logs, + workspaces, + pages, + ]) + + const searchQuery = search.trim() + /* Mode follows the DEFERRED query the ranking ran against — keying it on the + live value would flip the layout a frame before the entries agree with it. */ + const isSearching = Boolean(deferredSearch.trim()) + /** + * Section order for both the browse list and the flat search tie-break: the + * page's own entity section is hoisted directly under `actions`, the rest + * keep the canonical order. + */ + const hoistedSection = pageContext ? PAGE_CONTEXT_HOISTED_SECTION[pageContext] : undefined + const orderedSections = useMemo((): SearchSection[] => { + if (!hoistedSection) return [...SEARCH_SECTIONS] + return [ + 'actions', + hoistedSection, + ...SEARCH_SECTIONS.filter((section) => section !== 'actions' && section !== hoistedSection), + ] + }, [hoistedSection]) + const searchResults = useMemo( + () => + isSearching + ? getGlobalSearchResults(entriesBySection, orderedSections).slice(0, MAX_SEARCH_RESULTS) + : [], + [orderedSections, entriesBySection, isSearching] ) + const askSimLabel = searchQuery ? `New Chat: ${searchQuery}` : 'Start a new chat' + const sectionGroups = useMemo(() => { + if (isSearching) return [] + const actionEntriesByLabel = (label: ActionGroupLabel) => + entriesBySection.actions.filter( + (entry) => entry.section === 'actions' && getActionGroupLabel(entry.item) === label + ) + const entityGroup = (section: SearchSection) => ({ + key: section, + heading: SECTION_LABELS[section], + entries: entriesBySection[section], + }) - /** Connected accounts: visible on the integrations page even with empty input. */ - const filteredConnectedAccounts = useMemo(() => { - if (!isOnIntegrationsPage) return [] - return filterAndCap(connectedAccounts, (a) => a.name, deferredSearch) - }, [isOnIntegrationsPage, connectedAccounts, deferredSearch]) + const canvasSections = new Set(CANVAS_SECTIONS) + const groups = [ + ...(pageContext + ? [ + { + key: 'page-actions', + heading: 'Actions', + entries: actionEntriesByLabel('Actions'), + }, + ] + : []), + { + key: 'platform-actions', + heading: 'Sim', + entries: actionEntriesByLabel('Sim'), + }, + ...(hoistedSection ? [entityGroup(hoistedSection)] : []), + ...CANVAS_SECTIONS.map(entityGroup), + ...SEARCH_SECTIONS.filter( + (section) => + section !== 'actions' && section !== hoistedSection && !canvasSections.has(section) + ).map(entityGroup), + ] - /** Catalog integrations: only shown once the user has typed something. */ - const filteredIntegrations = useMemo(() => { - if (!isOnIntegrationsPage || !deferredSearch.trim()) return [] - return filterAndCap(integrations, (i) => i.name, deferredSearch) - }, [isOnIntegrationsPage, deferredSearch, integrations]) + let remaining = MAX_BROWSE_RESULTS + return groups.map((group) => { + if (group.entries.length <= remaining) { + remaining -= group.entries.length + return group + } + const truncated = { ...group, entries: group.entries.slice(0, remaining) } + remaining = 0 + return truncated + }) + }, [entriesBySection, pageContext, hoistedSection, isSearching]) - if (!mounted) return null + const entryHandlers = useMemo( + (): SearchEntryHandlers => ({ + onSelectAction: handleActionSelect, + onSelectBlock: handleBlockSelectAsBlock, + onSelectTool: handleBlockSelectAsTool, + onSelectTrigger: handleBlockSelectAsTrigger, + onSelectToolOperation: handleToolOperationSelect, + onSelectConnectedAccount: handleConnectedAccountSelect, + onSelectIntegration: handleIntegrationSelect, + onSelectChat: handleChatSelect, + onSelectWorkflow: handleWorkflowSelect, + onSelectTable: handleTableSelect, + onSelectFile: handleFileSelect, + onSelectKnowledgeBase: handleKbSelect, + onSelectLog: handleLogSelect, + onSelectWorkspace: handleWorkspaceSelect, + onSelectPage: handlePageSelect, + }), + [ + handleActionSelect, + handleBlockSelectAsBlock, + handleBlockSelectAsTool, + handleBlockSelectAsTrigger, + handleToolOperationSelect, + handleConnectedAccountSelect, + handleIntegrationSelect, + handleChatSelect, + handleWorkflowSelect, + handleTableSelect, + handleFileSelect, + handleKbSelect, + handleLogSelect, + handleWorkspaceSelect, + handlePageSelect, + ] + ) return createPortal( <>
{ - if ( - atomicBrowserOcclusion && - !open && - event.target === event.currentTarget && - event.propertyName === 'opacity' - ) { - setRetainNativeSurfaceOcclusion(false) - } - }} aria-hidden={!visuallyOpen} - data-native-surface-occlusion={nativeSurfaceOcclusionActive ? 'modal' : undefined} + data-native-surface-occlusion='modal' />
- -
- - +
+ + + No results found. + + + {askMode ? ( + + ) : isSearching ? ( + + ) : ( + sectionGroups.map(({ key, heading, entries }) => ( + + )) + )} + + + {askMode ? '⇥ Search' : '⇥ Ask Sim'} + + ) : undefined + } />
- - - No results found. - - - {showSection('actions') && ( - - )} - {showSection('connectedAccounts') && ( - - )} - {showSection('integrations') && ( - - )} - {showSection('blocks') && ( - - )} - {showSection('tools') && ( - - )} - {showSection('triggers') && ( - - )} - {showSection('chats') && ( - - )} - {showSection('tables') && ( - - )} - {showSection('files') && ( - - )} - {showSection('knowledgeBases') && ( - - )} - {showSection('workflows') && ( - - )} - {showSection('toolOperations') && ( - - )} - {showSection('workspaces') && ( - - )} - {showSection('docs') && } - {showSection('pages') && ( - - )} -
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.test.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.test.ts index 6f5d50a2cf8..55b51aee401 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.test.ts @@ -2,7 +2,221 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { filterAndCap, filterAndSort, fuzzyMatch, MAX_RESULTS_PER_GROUP } from './utils' +import { + ACTION_MATCH_BIAS, + filterAndCap, + filterAndSort, + fuzzyMatch, + getActionGroupLabel, + getGlobalSearchResults, + MAX_RESULTS_PER_GROUP, + type SearchEntry, + scoreActions, + scoreAndSort, + scoreSectionItems, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils' + +describe('getActionGroupLabel', () => { + const action = { + id: 'test-action', + name: 'Test action', + icon: () => null, + run: () => {}, + } + + it('separates page actions from Sim actions', () => { + expect(getActionGroupLabel({ ...action, context: 'workflow' })).toBe('Actions') + expect(getActionGroupLabel({ ...action, context: 'tables' })).toBe('Actions') + expect(getActionGroupLabel({ ...action, context: 'logsDashboard' })).toBe('Actions') + expect(getActionGroupLabel({ ...action, context: 'global' })).toBe('Sim') + }) + + it('lets an action group label surface actions whose names do not match', () => { + const workflowAction = { + ...action, + name: 'Fit canvas to view', + context: 'workflow' as const, + } + + expect(scoreActions([workflowAction], 'actions', 50, 'Actions')).toHaveLength(1) + expect(scoreActions([workflowAction], 'platform', 50, 'Actions')).toHaveLength(0) + }) +}) + +describe('getGlobalSearchResults', () => { + it('merge-ranks results across every visible section', () => { + const action: SearchEntry = { + section: 'actions', + score: 7, + item: { + id: 'create-folder', + name: 'Create folder', + icon: () => null, + context: 'global', + run: () => {}, + }, + } + const workflow: SearchEntry = { + section: 'workflows', + score: 20, + item: { id: 'workflow-1', name: 'New customer workflow', href: '/workflow-1' }, + } + const chat: SearchEntry = { + section: 'chats', + score: 83, + item: { id: 'chat-1', name: 'New chat', href: '/chat-1' }, + } + + const matches = getGlobalSearchResults( + { actions: [action], workflows: [workflow], chats: [chat] }, + ['actions', 'workflows', 'chats'] + ) + + expect(matches.map((entry) => entry.item.id)).toEqual(['chat-1', 'workflow-1', 'create-folder']) + }) + + it('biases matched actions above equal-quality matches from entity sections', () => { + const action = { + id: 'new-chat-action', + name: 'New chat', + keywords: 'message conversation', + icon: () => null, + context: 'global' as const, + run: () => {}, + } + const chat = { id: 'new-chat-result', name: 'New chat', href: '/new-chat-result' } + const [actionMatch] = scoreActions([action], 'new c') + const [chatMatch] = scoreAndSort([chat], (item) => item.name, 'new c') + + expect(actionMatch.score).toBe(chatMatch.score + ACTION_MATCH_BIAS) + expect( + getGlobalSearchResults( + { + actions: [{ section: 'actions', ...actionMatch }], + chats: [{ section: 'chats', ...chatMatch }], + }, + ['actions', 'chats'] + ).map((entry) => entry.item.id) + ).toEqual(['new-chat-action', 'new-chat-result']) + }) + + it('breaks identical visible-name matches by the original section order', () => { + const workflow = { id: 'new-chat-workflow', name: 'New chat', href: '/new-chat-workflow' } + const chat = { id: 'new-chat-result', name: 'New chat', href: '/new-chat-result' } + const [workflowMatch] = scoreAndSort([workflow], (item) => item.name, 'new c') + const [chatMatch] = scoreAndSort([chat], (item) => item.name, 'new c') + + expect(workflowMatch.score).toBe(chatMatch.score) + expect( + getGlobalSearchResults( + { + workflows: [{ section: 'workflows', ...workflowMatch }], + chats: [{ section: 'chats', ...chatMatch }], + }, + ['workflows', 'chats'] + ).map((entry) => entry.item.id) + ).toEqual(['new-chat-workflow', 'new-chat-result']) + }) + + it('keeps every matching entry in score order', () => { + const workflows: SearchEntry[] = Array.from({ length: 8 }, (_, index) => ({ + section: 'workflows', + score: index, + item: { id: `workflow-${index}`, name: `Workflow ${index}`, href: `/workflow-${index}` }, + })) + + expect( + getGlobalSearchResults({ workflows }, ['workflows']).map((entry) => entry.item.id) + ).toEqual([ + 'workflow-7', + 'workflow-6', + 'workflow-5', + 'workflow-4', + 'workflow-3', + 'workflow-2', + 'workflow-1', + 'workflow-0', + ]) + }) +}) + +describe('scoreSectionItems', () => { + it("surfaces a section's items when the query matches the section name", () => { + const chats = [{ name: 'Quarterly planning' }, { name: 'Incident follow-up' }] + + expect(scoreSectionItems('chats', chats, (chat) => chat.name, 'Chats')).toEqual([ + { item: chats[0], score: expect.any(Number) }, + { item: chats[1], score: expect.any(Number) }, + ]) + }) + + it('keeps direct matches first and preserves natural fallback order', () => { + const workspaces = [ + { name: 'Workspaces demo', keywords: 'long metadata' }, + { name: 'Acme', keywords: 'a much longer metadata value' }, + { name: 'Beta', keywords: '' }, + ] + + expect( + scoreSectionItems( + 'workspaces', + workspaces, + (workspace) => workspace.name, + 'workspaces', + (workspace) => workspace.keywords + ).map(({ item }) => item.name) + ).toEqual(['Workspaces demo', 'Acme', 'Beta']) + }) + + it('never fills or lifts tool operations from their section label', () => { + const operations = [{ name: 'Send Message' }, { name: 'Create Row' }] + + expect( + scoreSectionItems('toolOperations', operations, (op) => op.name, 'tool operations') + ).toHaveLength(0) + expect(scoreSectionItems('toolOperations', operations, (op) => op.name, 'tool')).toHaveLength(0) + expect( + scoreSectionItems('toolOperations', operations, (op) => op.name, 'send').map( + ({ item }) => item.name + ) + ).toEqual(['Send Message']) + }) + + it('lifts a whole section above other sections’ name matches when the query is exactly its name', () => { + const workflowItems = [ + { name: 'Onboarding' }, + { name: 'Billing sync' }, + { name: 'Workflow QA' }, + ] + const sectionScores = scoreSectionItems( + 'workflows', + workflowItems, + (item) => item.name, + 'workflows' + ) + const [chatMatch] = scoreAndSort( + [{ name: 'Workflows retro' }], + (item) => item.name, + 'workflows' + ) + + expect(sectionScores).toHaveLength(3) + expect(sectionScores.every(({ score }) => score > chatMatch.score)).toBe(true) + }) + + it('does not lift a section for a partial section-name query', () => { + const workflowItems = [{ name: 'Onboarding' }] + const [sectionFill] = scoreSectionItems( + 'workflows', + workflowItems, + (item) => item.name, + 'workflow' + ) + const [chatMatch] = scoreAndSort([{ name: 'Workflow retro' }], (item) => item.name, 'workflow') + + expect(sectionFill.score).toBeLessThan(chatMatch.score) + }) +}) /** * The matcher that shipped before fuzzy matching was introduced. Re-implemented @@ -294,6 +508,72 @@ describe('filterAndSort — name ranked above secondary text', () => { }) }) +describe('secondary-text matching — no scattered noise', () => { + it('does not scatter-match a query across long unrelated secondary text', () => { + const items = [{ name: 'Write Contact', extra: 'Wealthbox Write Contact match snap up' }] + + expect(fuzzyMatch(items[0].extra, 'whatsapp').matched).toBe(true) + expect( + filterAndSort( + items, + (item) => item.name, + 'whatsapp', + (item) => item.extra + ) + ).toEqual([]) + }) + + it('still matches secondary text by substring and by whole tokens', () => { + const items = [{ name: 'Send Message', extra: 'Slack Send Message dm chat' }] + + expect( + filterAndSort( + items, + (item) => item.name, + 'slack', + (item) => item.extra + ) + ).toHaveLength(1) + expect( + filterAndSort( + items, + (item) => item.name, + 'slack chat', + (item) => item.extra + ) + ).toHaveLength(1) + expect( + filterAndSort( + items, + (item) => item.name, + 'whatsapp', + (item) => item.extra + ) + ).toHaveLength(0) + }) + + it('scatter-matches within a single kebab-cased entry but never across entries', () => { + const items = [{ name: 'Do Thing', extra: 'slack send-message dm chat' }] + + expect( + filterAndSort( + items, + (item) => item.name, + 'sndmsg', + (item) => item.extra + ) + ).toHaveLength(1) + expect( + filterAndSort( + items, + (item) => item.name, + 'dmchat', + (item) => item.extra + ) + ).toHaveLength(0) + }) +}) + describe('filterAndCap', () => { const id = (s: string) => s diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts index c43d6085fa8..00f32405d08 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts @@ -1,4 +1,39 @@ import type { ComponentType } from 'react' +import { toSearchToken } from '@/lib/search/tokens' +import type { SearchBlockItem, SearchToolOperationItem } from '@/stores/modals/search/types' + +/** + * Every result group the palette can render. This is also the canonical order: + * the zero-query browse list and the flat search tie-break both follow it, + * with two page-aware insertions at the front — the page's action group, then + * its own entity section hoisted above `actions`' platform group. + */ +export const SEARCH_SECTIONS = [ + 'actions', + 'blocks', + 'triggers', + 'tools', + 'toolOperations', + 'pages', + 'workflows', + 'workspaces', + 'files', + 'tables', + 'knowledgeBases', + 'logs', + 'connectedAccounts', + 'chats', + 'integrations', +] as const + +/** A single search-modal result group. */ +export type SearchSection = (typeof SEARCH_SECTIONS)[number] + +/** + * Canvas building-block sections. They render between the page's action group + * and the Sim group; off the canvas they carry no items and render nothing. + */ +export const CANVAS_SECTIONS = ['blocks', 'triggers', 'tools', 'toolOperations'] as const export interface IntegrationSearchItem { id: string @@ -12,6 +47,8 @@ export interface TaskItem { id: string name: string href: string + /** Formatted last-activity date shown as trailing metadata. Set for chats. */ + date?: string } /** @@ -19,6 +56,7 @@ export interface TaskItem { * folder it came from — a name is only unique within its folder. */ export interface FolderedItem extends TaskItem { + /** Owning folder names, root first. */ folderPath?: string[] } @@ -31,6 +69,8 @@ export interface WorkspaceItem { name: string href: string isCurrent?: boolean + logoUrl?: string | null + color?: string } export interface PageItem { @@ -45,8 +85,33 @@ export interface PageItem { export type FileItem = FolderedItem +export interface LogItem { + id: string + /** Workflow (or job) name the execution belongs to. */ + name: string + href: string + /** Human-readable run date shown as trailing metadata. */ + date: string +} + +/** + * Pages that contribute their own palette actions while active. Each page + * registers its handlers as global commands on mount; the palette invokes + * them by id and only offers them while the matching route is mounted. + */ +export type PageActionContext = + | 'workflow' + | 'tables' + | 'tableDetail' + | 'files' + | 'fileDetail' + | 'knowledge' + | 'knowledgeBase' + | 'logs' + | 'logsDashboard' + /** Where an {@link ActionItem} (a verb) is available. */ -export type ActionContext = 'global' | 'workflow' | 'integrations' +export type ActionContext = 'global' | PageActionContext /** * An action is a verb the palette can run directly (create, import, toggle), @@ -59,12 +124,42 @@ export interface ActionItem { name: string /** Extra terms folded into the search value (e.g. "new add"). */ keywords?: string + /** + * Lowercase queries that name this action outright — the module it heads + * (`'workflows'` for Create workflow) or its bare verb (`'deploy'`, + * `'copy'`). When the trimmed query IS one of these, the action ranks like + * a page row ({@link PAGE_MATCH_TIER}), above section-lifted and + * exact-name entity rows. + */ + exactQueries?: readonly string[] icon: ComponentType<{ className?: string }> shortcut?: string context: ActionContext run: () => void } +export type ActionGroupLabel = 'Sim' | 'Actions' + +/** + * The page's own entity section, hoisted directly under its action group in + * both the browse list and the search tie-break. + */ +export const PAGE_CONTEXT_HOISTED_SECTION: Partial> = { + tables: 'tables', + tableDetail: 'tables', + files: 'files', + fileDetail: 'files', + knowledge: 'knowledgeBases', + knowledgeBase: 'knowledgeBases', + logs: 'logs', + logsDashboard: 'logs', +} + +/** Presentation group for an action without changing its stable result identity. */ +export function getActionGroupLabel(action: ActionItem): ActionGroupLabel { + return action.context === 'global' ? 'Sim' : 'Actions' +} + export interface SearchModalProps { open: boolean onOpenChange: (open: boolean) => void @@ -74,11 +169,13 @@ export interface SearchModalProps { tables?: FolderedItem[] files?: FileItem[] knowledgeBases?: FolderedItem[] + logs?: LogItem[] integrations?: IntegrationSearchItem[] connectedAccounts?: IntegrationSearchItem[] - isOnWorkflowPage?: boolean - isOnIntegrationsPage?: boolean + /** Page the palette was opened on, when that page contributes actions. */ + pageContext?: PageActionContext | null canEdit?: boolean + canAdmin?: boolean onCreateWorkflow?: () => void onCreateFolder?: () => void onImportWorkflow?: () => void @@ -98,13 +195,86 @@ export interface CommandItemProps { workflowType?: string /** Primary text of the row. */ label: string + /** De-emphasized lead-in before the label (e.g. a tool operation's service). */ + labelPrefix?: string + /** Right-aligned trailing metadata. */ + meta?: string } +export const SECTION_LABELS: Record = { + actions: 'Sim', + blocks: 'Blocks', + triggers: 'Triggers', + tools: 'Tools', + toolOperations: 'Tool operations', + pages: 'Pages', + workflows: 'Workflows', + workspaces: 'Workspaces', + files: 'Files', + tables: 'Tables', + knowledgeBases: 'Knowledge Bases', + logs: 'Logs', + connectedAccounts: 'Connected Integrations', + integrations: 'Integrations', + chats: 'Chats', +} + +export type SearchEntry = + | { section: 'actions'; score: number; item: ActionItem } + | { section: 'blocks' | 'tools' | 'triggers'; score: number; item: SearchBlockItem } + | { section: 'toolOperations'; score: number; item: SearchToolOperationItem } + | { section: 'connectedAccounts' | 'integrations'; score: number; item: IntegrationSearchItem } + | { section: 'chats'; score: number; item: TaskItem } + | { section: 'workflows'; score: number; item: WorkflowItem } + | { section: 'tables' | 'knowledgeBases'; score: number; item: FolderedItem } + | { section: 'files'; score: number; item: FileItem } + | { section: 'logs'; score: number; item: LogItem } + | { section: 'workspaces'; score: number; item: WorkspaceItem } + | { section: 'pages'; score: number; item: PageItem } + +export interface SearchEntryHandlers { + onSelectAction: (item: ActionItem) => void + onSelectBlock: (item: SearchBlockItem) => void + onSelectTool: (item: SearchBlockItem) => void + onSelectTrigger: (item: SearchBlockItem) => void + onSelectToolOperation: (item: SearchToolOperationItem) => void + onSelectConnectedAccount: (item: IntegrationSearchItem) => void + onSelectIntegration: (item: IntegrationSearchItem) => void + onSelectChat: (item: TaskItem) => void + onSelectWorkflow: (item: WorkflowItem) => void + onSelectTable: (item: FolderedItem) => void + onSelectFile: (item: FileItem) => void + onSelectKnowledgeBase: (item: FolderedItem) => void + onSelectLog: (item: LogItem) => void + onSelectWorkspace: (item: WorkspaceItem) => void + onSelectPage: (item: PageItem) => void +} + +/** Merge-ranks every match from the visible sections into one flat result list. */ +export function getGlobalSearchResults( + entriesBySection: Partial>, + sections: readonly SearchSection[] +): SearchEntry[] { + /* Flattening in section order makes the spec-stable sort's tie-break the + section order (then within-section order) with no explicit comparator. */ + return sections + .flatMap((section) => entriesBySection[section] ?? []) + .sort((a, b) => b.score - a.score) +} + +/** + * `scroll-mt-12` mirrors the list's `pt-12`: the search input floats over the + * top 48px of the scrollport, and cmdk keeps the selection visible with + * `scrollIntoView({ block: 'nearest' })` — without the scroll margin, arrowing + * upward (or loop-wrapping to the first row) parks the row under the input. + * Group headings need the same margin because cmdk scrolls the heading into + * view when the selection is its group's first row. + */ export const GROUP_HEADING_CLASSNAME = - '[&_[cmdk-group-heading]]:flex [&_[cmdk-group-heading]]:h-[18px] [&_[cmdk-group-heading]]:items-center [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:mb-2 [&_[cmdk-group-heading]]:text-small [&_[cmdk-group-heading]]:text-[var(--text-muted)]' + '[&_[cmdk-group-heading]]:flex [&_[cmdk-group-heading]]:h-[18px] [&_[cmdk-group-heading]]:items-center [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:mb-2 [&_[cmdk-group-heading]]:scroll-mt-12 [&_[cmdk-group-heading]]:text-small [&_[cmdk-group-heading]]:text-[var(--text-muted)]' export const COMMAND_ITEM_CLASSNAME = - 'group mx-0.5 flex h-[30px] w-full cursor-pointer items-center gap-2 rounded-lg border border-transparent px-2 text-left text-sm aria-selected:border-[var(--border-1)] aria-selected:bg-[var(--surface-active)] data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50' + 'group mx-0.5 flex h-[30px] w-full cursor-pointer items-center gap-2 rounded-lg border border-transparent px-2 text-left text-sm scroll-mt-12 scroll-mb-1.5 aria-selected:border-[var(--border-1)] aria-selected:bg-[var(--surface-active)] data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50' /** Characters that begin a new word — a match here scores higher. */ const SEPARATORS = new Set([' ', '-', '_', '/', '.', ':', '(', ')']) @@ -186,8 +356,18 @@ function tokenFallback(lowerText: string, lowerQuery: string): FuzzyResult { * * Contiguous substring matches report the indices of the substring itself * rather than an earlier scattered occurrence of the same characters. + * + * Pass `scatter: false` to skip the scattered-subsequence mode. Over long + * multi-word text (alias lists, option labels) a scattered query matches + * almost anything — "whatsapp" finds `w…h…a…t…s…a…p…p` across unrelated alias + * words — so secondary-text matching keeps only the exact/prefix/substring + * and multi-word token modes. */ -export function fuzzyMatch(text: string, query: string): FuzzyResult { +export function fuzzyMatch( + text: string, + query: string, + options?: { scatter?: boolean } +): FuzzyResult { if (!query) return { matched: true, score: 1, positions: [] } if (!text) return NO_MATCH @@ -215,6 +395,8 @@ export function fuzzyMatch(text: string, query: string): FuzzyResult { return { matched: true, score, positions } } + if (options?.scatter === false) return tokenFallback(lowerText, lowerQuery) + const positions: number[] = [] let queryIndex = 0 let score = 0 @@ -248,22 +430,190 @@ export function fuzzyMatch(text: string, query: string): FuzzyResult { /** Rank offset that lifts every name match above any secondary-text match. */ const NAME_MATCH_TIER = 1_000_000 +/** + * Rank offset that lifts an entire section above every name match when the + * query IS that section's name — typing "triggers" asks for the Triggers + * section itself, not rows from other sections that happen to contain the word. + */ +const SECTION_MATCH_TIER = 2_000_000 + +/** + * Rank offset for a page row whose name IS the query. Typing "logs" means the + * Logs page itself first, then its contents (the section lifted into + * {@link SECTION_MATCH_TIER}) beneath it. + */ +export const PAGE_MATCH_TIER = 3_000_000 + +/** + * Matches a query against secondary search text: a space-separated list of + * entries where multi-word phrases are kebab-cased into single tokens (see + * `toSearchToken`). Whole-string matching keeps the exact/prefix/substring and + * multi-word token modes; scattered matching runs against each entry + * individually, so a query can scatter within one entry ("sndmsg" → + * "send-message") but never assemble itself across unrelated entries + * ("whatsapp" must not match "wealthbox-write-contact match snap up"). + */ +/** + * Secondary-text strings are stable catalog data (block/tool/operation search + * values), so their word splits are cached — the palette re-matches every + * miss on every keystroke, and re-splitting dominated that loop. + */ +const secondaryTextWords = new Map() + +function matchSecondaryText(extra: string, query: string): FuzzyResult { + const whole = fuzzyMatch(extra, query, { scatter: false }) + let best = whole.matched ? whole : NO_MATCH + let words = secondaryTextWords.get(extra) + if (!words) { + words = extra.split(/\s+/) + secondaryTextWords.set(extra, words) + } + for (const word of words) { + const byWord = fuzzyMatch(word, query) + if (byWord.matched && (!best.matched || byWord.score > best.score)) best = byWord + } + return best +} + /** * Ranks an item by its name first, falling back to secondary text (ids, aliases, * option labels) only when the name doesn't match — a name match always wins, so * an exact name hit isn't diluted by a long secondary string ("Agent" beats * "Pi Coding Agent" for the query "agent"). */ -function scoreItem(name: string, extra: string | undefined, search: string): FuzzyResult { +function scoreItem(name: string, search: string, getExtra?: () => string | undefined): FuzzyResult { const byName = fuzzyMatch(name, search) - if (!extra) return byName if (byName.matched) { return { matched: true, score: byName.score + NAME_MATCH_TIER, positions: byName.positions } } - const byExtra = fuzzyMatch(extra, search) + const extra = getExtra?.() + if (!extra) return NO_MATCH + const byExtra = matchSecondaryText(extra, search) return byExtra.matched ? byExtra : NO_MATCH } +/** Scores and sorts matches while retaining scores for cross-section ranking. */ +export function scoreAndSort( + items: T[], + toValue: (item: T) => string, + search: string, + toExtra?: (item: T) => string | undefined +): Array<{ item: T; score: number }> { + const query = search.trim() + const scored: Array<{ item: T; score: number }> = [] + for (const item of items) { + const { matched, score } = scoreItem( + toValue(item), + query, + toExtra ? () => toExtra(item) : undefined + ) + if (matched) scored.push({ item, score }) + } + scored.sort((a, b) => b.score - a.score) + return scored +} + +/** + * Scores normal item matches first, then fills a matched section with its + * remaining rows in natural order. A query that exactly names the section + * lifts every returned row into {@link SECTION_MATCH_TIER}, keeping this + * internal order but beating name matches from other sections. + */ +function scoreItemsForSection( + sectionLabel: string, + items: T[], + toValue: (item: T) => string, + search: string, + toExtra?: (item: T) => string | undefined, + maxResults = Number.POSITIVE_INFINITY +): Array<{ item: T; score: number }> { + const rankedItems = scoreAndSort(items, toValue, search, toExtra) + const query = search.trim() + const sectionMatch = fuzzyMatch(sectionLabel, query) + const isExactLabelMatch = + sectionMatch.matched && query.toLowerCase() === sectionLabel.toLowerCase() + + let results: Array<{ item: T; score: number }> + if (!sectionMatch.matched || rankedItems.length >= maxResults) { + results = rankedItems.slice(0, maxResults) + } else { + const matchedItems = new Set(rankedItems.map(({ item }) => item)) + const lowestItemScore = rankedItems.at(-1)?.score + const fallbackScore = + lowestItemScore === undefined + ? sectionMatch.score + : Math.min(sectionMatch.score, lowestItemScore - 1) + + results = [...rankedItems] + for (const item of items) { + if (!matchedItems.has(item)) results.push({ item, score: fallbackScore }) + if (results.length >= maxResults) break + } + } + + if (isExactLabelMatch) { + return results.map(({ item }, index) => ({ item, score: SECTION_MATCH_TIER - index })) + } + return results +} + +/** + * Sections whose label never participates in matching. Tool operations are a + * 1000+ registry-ordered list, so label-driven behavior ("tool operations" + * lifting the section, or a partial hit like "tool" filling it) would surface + * arbitrary rows; individual operations stay searchable by name and alias. + */ +const LABEL_MATCH_EXEMPT_SECTIONS = new Set(['toolOperations']) + +export function scoreSectionItems( + section: SearchSection, + items: T[], + toValue: (item: T) => string, + search: string, + toExtra?: (item: T) => string | undefined, + maxResults = Number.POSITIVE_INFINITY +): Array<{ item: T; score: number }> { + if (LABEL_MATCH_EXEMPT_SECTIONS.has(section)) { + return scoreAndSort(items, toValue, search, toExtra).slice(0, maxResults) + } + return scoreItemsForSection(SECTION_LABELS[section], items, toValue, search, toExtra, maxResults) +} + +/** + * Rank offset added to every matched action. Actions are the palette's few + * runnable verbs, so a matched action outranks entity rows of the same match + * quality — a name-matched action beats name-matched entities, a + * keyword-matched action beats other secondary-text matches — while the + * half-tier offset deliberately cannot bridge into the next tier up + * ({@link SECTION_MATCH_TIER}, {@link PAGE_MATCH_TIER}). + */ +export const ACTION_MATCH_BIAS = 500_000 + +/** + * Scores actions by visible name before falling back to their keywords. + * Every match is lifted by {@link ACTION_MATCH_BIAS}; a query listed in the + * action's `exactQueries` ranks it like a page row instead. + */ +export function scoreActions( + actions: ActionItem[], + search: string, + maxResults = Number.POSITIVE_INFINITY, + groupLabel: ActionGroupLabel = 'Sim' +): Array<{ item: ActionItem; score: number }> { + const query = search.trim().toLowerCase() + return scoreItemsForSection( + groupLabel, + actions, + (action) => action.name, + search, + (action) => `${toSearchToken(action.name)} ${action.keywords ?? ''}`, + maxResults + ).map(({ item, score }) => ({ + item, + score: item.exactQueries?.includes(query) ? PAGE_MATCH_TIER : score + ACTION_MATCH_BIAS, + })) +} + /** * Filters and ranks items by fuzzy match, highest score first; returns the input * unchanged when the search is empty or whitespace-only. Pass `toExtra` to rank @@ -275,15 +625,8 @@ export function filterAndSort( search: string, toExtra?: (item: T) => string | undefined ): T[] { - const query = search.trim() - if (!query) return items - const scored: Array<{ item: T; score: number }> = [] - for (const item of items) { - const { matched, score } = scoreItem(toValue(item), toExtra?.(item), query) - if (matched) scored.push({ item, score }) - } - scored.sort((a, b) => b.score - a.score) - return scored.map((entry) => entry.item) + if (!search.trim()) return items + return scoreAndSort(items, toValue, search, toExtra).map((entry) => entry.item) } /** 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 210239db68a..b5aa4e82855 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -66,6 +66,10 @@ import { buildConnectedAccountSearchItems, buildIntegrationSearchItems, } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/integration-search-items' +import type { + LogItem, + PageActionContext, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils' import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu' import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal' import { @@ -95,6 +99,7 @@ import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay' import { useWorkspaceCredentials } from '@/hooks/queries/credentials' import { useFolderMap, useFolders } from '@/hooks/queries/folders' import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge' +import { type LogFilters, useLogsList } from '@/hooks/queries/logs' import type { MothershipChatMetadata } from '@/hooks/queries/mothership-chats' import { useDeleteMothershipChat, @@ -115,6 +120,7 @@ import { useSettingsNavigation } from '@/hooks/use-settings-navigation' import { SIDEBAR_WIDTH } from '@/stores/constants' import { useFolderStore } from '@/stores/folders/store' import type { WorkflowFolder } from '@/stores/folders/types' +import { useFilterStore } from '@/stores/logs/filters/store' import { useSearchModalStore } from '@/stores/modals/search/store' import { useProvidersStore } from '@/stores/providers' import { useSettingsDirtyStore } from '@/stores/settings/dirty/store' @@ -131,6 +137,27 @@ const EMPTY_CHATS: MothershipChatMetadata[] = [] /** Stable identity while a folder list loads, so the search-row memos don't churn. */ const EMPTY_FOLDER_MAP: Record = {} +/** Recent runs shown in the palette's Logs section on the logs pages. */ +const SEARCH_MODAL_LOG_FILTERS: LogFilters = { + timeRange: 'All time', + level: 'all', + workflowIds: [], + folderIds: [], + triggers: [], + searchQuery: '', + limit: 50, + sortBy: 'date', + sortOrder: 'desc', +} + +/** Short run/activity date for palette row receipts (logs, chats). */ +const SEARCH_MODAL_DATE_FORMAT = new Intl.DateTimeFormat(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', +}) + const SLACK_COMMUNITY_URL = 'https://join.slack.com/t/sim-ott9864/shared_invite/zt-43lp8tc5v-0qrrqHGBKUsvQlpoouH~TA' @@ -424,7 +451,7 @@ export const Sidebar = memo(function Sidebar({ const posthog = usePostHog() const { data: sessionData, isPending: sessionLoading } = useSession() const { workspace: routeWorkspace } = useWorkspaceHostContext() - const { canEdit, isLoading: permissionsLoading } = useUserPermissionsContext() + const { canAdmin, canEdit, isLoading: permissionsLoading } = useUserPermissionsContext() const { config: permissionConfig, filterBlocks, @@ -771,6 +798,8 @@ export const Sidebar = memo(function Sidebar({ name: workspace.name, href: `/workspace/${workspace.id}/w`, isCurrent: workspace.id === workspaceId, + logoUrl: workspace.logoUrl, + color: workspace.color, })), [workspaces, workspaceId] ) @@ -867,6 +896,7 @@ export const Sidebar = memo(function Sidebar({ fetchedChats.map((t) => ({ ...t, href: `/workspace/${workspaceId}/chat/${t.id}`, + date: SEARCH_MODAL_DATE_FORMAT.format(t.updatedAt), })), [fetchedChats, workspaceId] ) @@ -1078,13 +1108,57 @@ export const Sidebar = memo(function Sidebar({ }, []) const isOnSettingsPage = pathname?.startsWith(`/workspace/${workspaceId}/settings`) ?? false - const isOnIntegrationsPage = - pathname?.startsWith(`/workspace/${workspaceId}/integrations`) ?? false + + const logsViewMode = useFilterStore((state) => state.viewMode) + + /** + * Page whose registered palette commands are currently invocable. Matches + * only routes that mount the registering component: list pages exactly, and + * detail roots as a single path segment (deeper routes don't mount them). + */ + const searchModalPageContext = useMemo((): PageActionContext | null => { + if (!pathname) return null + if (workflowId) return 'workflow' + const base = `/workspace/${workspaceId}` + const detailSegment = (prefix: string): string | null => { + if (!pathname.startsWith(prefix)) return null + const rest = pathname.slice(prefix.length) + return rest && !rest.includes('/') ? rest : null + } + if (pathname === `${base}/tables`) return 'tables' + if (detailSegment(`${base}/tables/`)) return 'tableDetail' + if (pathname === `${base}/files`) return 'files' + if (detailSegment(`${base}/files/`)) return 'fileDetail' + if (pathname === `${base}/knowledge`) return 'knowledge' + if (detailSegment(`${base}/knowledge/`)) return 'knowledgeBase' + if (pathname === `${base}/logs`) return logsViewMode === 'dashboard' ? 'logsDashboard' : 'logs' + return null + }, [pathname, workspaceId, workflowId, logsViewMode]) const { data: fetchedCredentials = [] } = useWorkspaceCredentials({ workspaceId, - enabled: isOnIntegrationsPage && !permissionConfig.hideIntegrationsTab, + enabled: + isSearchModalOpen && + !permissionConfig.hideIntegrationsTab && + searchModalPageContext !== 'workflow', + }) + + const isOnLogsPage = + searchModalPageContext === 'logs' || searchModalPageContext === 'logsDashboard' + const logsPages = useLogsList(workspaceId, SEARCH_MODAL_LOG_FILTERS, { + enabled: isSearchModalOpen && isOnLogsPage, }) + const searchModalLogs = useMemo((): LogItem[] => { + const rows = logsPages.data?.pages[0]?.logs ?? [] + return rows.map((log) => ({ + id: log.id, + name: log.workflow?.name || log.jobTitle || 'Unknown workflow', + href: log.executionId + ? `/workspace/${workspaceId}/logs?executionId=${log.executionId}` + : `/workspace/${workspaceId}/logs`, + date: SEARCH_MODAL_DATE_FORMAT.format(new Date(log.createdAt)), + })) + }, [logsPages.data, workspaceId]) const searchModalIntegrations = useMemo( () => @@ -1297,7 +1371,8 @@ export const Sidebar = memo(function Sidebar({ { id: 'open-search', handler: () => { - openSearchModal() + const searchModal = useSearchModalStore.getState() + searchModal.setOpen(!searchModal.isOpen) }, }, { @@ -1851,11 +1926,12 @@ export const Sidebar = memo(function Sidebar({ tables={searchModalTables} files={searchModalFiles} knowledgeBases={searchModalKnowledgeBases} + logs={searchModalLogs} integrations={searchModalIntegrations} connectedAccounts={searchModalConnectedAccounts} - isOnWorkflowPage={!!workflowId} - isOnIntegrationsPage={isOnIntegrationsPage} + pageContext={searchModalPageContext} canEdit={canEdit} + canAdmin={canAdmin} onCreateWorkflow={handleCreateWorkflow} onCreateFolder={handleCreateFolder} onImportWorkflow={handleImportWorkflow} diff --git a/apps/sim/lib/posthog/events.ts b/apps/sim/lib/posthog/events.ts index 48462b7be1b..27be0298ee9 100644 --- a/apps/sim/lib/posthog/events.ts +++ b/apps/sim/lib/posthog/events.ts @@ -575,10 +575,8 @@ export interface PostHogEventMap { | 'table' | 'file' | 'knowledge_base' + | 'log' | 'page' - | 'docs' - | 'connected_account' - | 'integration' | 'action' query_length: number workspace_id: string diff --git a/apps/sim/lib/search/tokens.ts b/apps/sim/lib/search/tokens.ts new file mode 100644 index 00000000000..534870180c9 --- /dev/null +++ b/apps/sim/lib/search/tokens.ts @@ -0,0 +1,13 @@ +/** + * Collapses a multi-word phrase into a single kebab-case search token. + * + * Secondary search text (aliases, service names, folder paths, option labels) + * is a space-separated list of entries, and the palette's scattered fuzzy + * matching is confined to one whitespace-delimited word at a time. Kebab-casing + * a multi-word entry keeps it scatter-matchable as a unit ("sndmsg" finds + * "send-message") while a query can never be assembled letter-by-letter across + * unrelated entries. + */ +export function toSearchToken(value: string): string { + return value.trim().split(/\s+/).join('-') +} diff --git a/apps/sim/stores/modals/search/store.test.ts b/apps/sim/stores/modals/search/store.test.ts index 36492582e1d..1d96b0a4662 100644 --- a/apps/sim/stores/modals/search/store.test.ts +++ b/apps/sim/stores/modals/search/store.test.ts @@ -69,7 +69,6 @@ describe('search modal store', () => { tools: [], triggers: [], toolOperations: [], - docs: [], isInitialized: false, }, }) @@ -81,7 +80,7 @@ describe('search modal store', () => { const searchValue = buildCommandSearchableOptionSearchValue(block) expect(searchValue).toContain('Provider') - expect(searchValue).toContain('Fal.ai (Multi-Model)') + expect(searchValue).toContain('Fal.ai-(Multi-Model)') expect(searchValue).toContain('falai') expect(searchValue).not.toContain('Hidden Provider') expect(searchValue).not.toContain('hidden') @@ -144,7 +143,7 @@ describe('search modal store', () => { expect(tools[0]).toEqual( expect.objectContaining({ id: 'image_generator_v2', - searchValue: expect.stringContaining('Fal.ai (Multi-Model)'), + searchValue: expect.stringContaining('Fal.ai-(Multi-Model)'), }) ) }) diff --git a/apps/sim/stores/modals/search/store.ts b/apps/sim/stores/modals/search/store.ts index f21d516ddc4..337583e9672 100644 --- a/apps/sim/stores/modals/search/store.ts +++ b/apps/sim/stores/modals/search/store.ts @@ -1,6 +1,7 @@ import { Repeat, Split } from '@sim/emcn/icons' import { create } from 'zustand' import { devtools } from 'zustand/middleware' +import { toSearchToken } from '@/lib/search/tokens' import { getToolOperationsIndex } from '@/lib/search/tool-operations' import { getTriggersForSidebar } from '@/lib/workflows/triggers/trigger-utils' import { getAllBlocks } from '@/blocks' @@ -8,7 +9,6 @@ import type { BlockConfig, SubBlockConfig } from '@/blocks/types' import type { SearchBlockItem, SearchData, - SearchDocItem, SearchModalState, SearchToolOperationItem, } from './types' @@ -18,7 +18,6 @@ const initialData: SearchData = { tools: [], triggers: [], toolOperations: [], - docs: [], isInitialized: false, } @@ -54,8 +53,8 @@ export function buildCommandSearchableOptionSearchValue(block: BlockConfig): str if (option.hidden) continue const subBlockTitle = subBlock.title ?? subBlock.id - terms.add(subBlockTitle) - terms.add(option.label) + terms.add(toSearchToken(subBlockTitle)) + terms.add(toSearchToken(option.label)) terms.add(option.id) } } @@ -67,24 +66,18 @@ export const useSearchModalStore = create()( devtools( (set, _) => ({ isOpen: false, - sections: null, - pendingConnect: null, data: initialData, setOpen: (open: boolean) => { - set({ isOpen: open, sections: null, pendingConnect: null }) + set({ isOpen: open }) }, - open: (options) => { - set({ - isOpen: true, - sections: options?.sections ?? null, - pendingConnect: options?.pendingConnect ?? null, - }) + open: () => { + set({ isOpen: true }) }, close: () => { - set({ isOpen: false, sections: null, pendingConnect: null }) + set({ isOpen: false }) }, initializeData: (filterBlocks) => { @@ -93,7 +86,6 @@ export const useSearchModalStore = create()( const regularBlocks: SearchBlockItem[] = [] const tools: SearchBlockItem[] = [] - const docs: SearchDocItem[] = [] for (const block of filteredAllBlocks) { if (block.hideFromToolbar) continue @@ -104,7 +96,7 @@ export const useSearchModalStore = create()( icon: block.icon, bgColor: block.bgColor || '#6B7280', type: block.type, - searchValue: `${block.name} ${block.type} ${buildCommandSearchableOptionSearchValue(block)}`, + searchValue: `${toSearchToken(block.name)} ${block.type} ${buildCommandSearchableOptionSearchValue(block)}`, sourceWorkflowId: block.sourceWorkflowId, } @@ -113,15 +105,6 @@ export const useSearchModalStore = create()( } 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[] = [ @@ -176,11 +159,14 @@ export const useSearchModalStore = create()( const toolOperations: SearchToolOperationItem[] = getToolOperationsIndex() .filter((op) => allowedBlockTypes.has(op.blockType)) .map((op) => { - const aliasesStr = op.aliases?.length ? ` ${op.aliases.join(' ')}` : '' + const aliasesStr = op.aliases?.length + ? ` ${op.aliases.map(toSearchToken).join(' ')}` + : '' return { id: op.id, name: op.operationName, - searchValue: `${op.serviceName} ${op.operationName}${aliasesStr}`, + serviceName: op.serviceName, + searchValue: `${toSearchToken(op.serviceName)} ${toSearchToken(op.operationName)}${aliasesStr}`, icon: op.icon, bgColor: op.bgColor, blockType: op.blockType, @@ -194,7 +180,6 @@ export const useSearchModalStore = create()( tools, triggers, toolOperations, - docs, isInitialized: true, }, }) diff --git a/apps/sim/stores/modals/search/types.ts b/apps/sim/stores/modals/search/types.ts index c3e8feb50f6..e7f8c7cee39 100644 --- a/apps/sim/stores/modals/search/types.ts +++ b/apps/sim/stores/modals/search/types.ts @@ -22,6 +22,7 @@ export interface SearchBlockItem { export interface SearchToolOperationItem { id: string name: string + serviceName: string searchValue: string icon: ComponentType<{ className?: string }> bgColor: string @@ -29,16 +30,6 @@ export interface SearchToolOperationItem { operationId: 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. */ @@ -47,37 +38,9 @@ export interface SearchData { tools: SearchBlockItem[] triggers: SearchBlockItem[] toolOperations: SearchToolOperationItem[] - docs: SearchDocItem[] isInitialized: boolean } -/** - * Every result group the search modal can render, in render order. Used to - * restrict the palette to a subset of sections when opened for a specific - * intent (e.g. a drag-release that should only offer canvas-insertable items). - */ -export const SEARCH_SECTIONS = [ - 'actions', - 'connectedAccounts', - 'integrations', - 'blocks', - 'tools', - 'triggers', - // Resource groups follow the sidebar's top-down order. - 'chats', - 'tables', - 'files', - 'knowledgeBases', - 'workflows', - 'toolOperations', - 'workspaces', - 'docs', - 'pages', -] as const - -/** A single search-modal result group. */ -export type SearchSection = (typeof SEARCH_SECTIONS)[number] - /** * Context handed to the palette when it is opened to complete an edge * drag-release: the dragged source handle and the release point. A selection @@ -95,43 +58,23 @@ export interface PendingConnect { * * Centralizing this state in a store allows any component (e.g. sidebar, * workflow command list, keyboard shortcuts) to open or close the modal - * without relying on DOM events or prop drilling. + * without relying on DOM events or prop drilling. The pre-computed block data + * also feeds the canvas connection block selector. */ export interface SearchModalState { /** Whether the search modal is currently open. */ isOpen: boolean - /** - * When set, the palette renders only these sections; `null` shows all of them. - */ - sections: SearchSection[] | null - - /** - * Pending edge drag-release the palette was opened to complete. A selection - * stamps it onto its event; other add-block dispatchers carry none, so only a - * genuine palette pick completes the connection. `null` for ordinary opens. - */ - pendingConnect: PendingConnect | null - - /** Pre-computed search data. */ + /** Pre-computed block/tool search data (consumed by the canvas selector). */ data: SearchData - /** - * Explicitly set the open state of the modal. Always resets to the full - * palette (no section restriction, no pending connect). - */ + /** Explicitly set the open state of the modal. */ setOpen: (open: boolean) => void - /** - * Convenience method to open the modal. Pass `sections` to restrict the - * palette to a subset of result groups, and `pendingConnect` to complete an - * edge drag-release with the selection. - */ - open: (options?: { sections?: SearchSection[]; pendingConnect?: PendingConnect }) => void + /** Convenience method to open the modal. */ + open: () => void - /** - * Convenience method to close the modal. - */ + /** Convenience method to close the modal. */ close: () => void /**