feat(search): actions, fuzzy matching, and highlighting in cmd+k palette (#5110)

* feat(search): actions, fuzzy matching, and highlighting in cmd+k palette

Add a context-aware actions layer to the cmd+k search palette (Run workflow,
Create workflow/folder, Import workflow, Fit to view, Copy link, Invite
teammates, Toggle theme), replace the substring matcher with a boundary-anchored
fuzzy matcher (initialisms, typos, multi-word) that is a strict superset of the
old behavior, highlight matched characters, and rank against clean human text
instead of structural id/uuid tokens. Expose invoke() on the global commands
provider so the palette runs real registered commands.

* fix(search): highlight the matched substring, not an earlier scattered occurrence

Contiguous substring matches (exact/prefix/contains) now report the substring's
own indices instead of the greedy subsequence scan positions, so HighlightedText
bolds the characters the user actually matched. Restructures fuzzyMatch to handle
the substring tier first; scores are unchanged for these cases.

* fix(search): log clipboard copy failures and make fuzzy positions read-only

- Copy workflow link now logs on clipboard write failure instead of silently
  swallowing the error, matching the sidebar's copy-link convention.
- FuzzyResult.positions is now readonly and the NO_MATCH singleton's array is
  frozen, so the shared instance can never be mutated by a caller.
This commit is contained in:
Waleed
2026-06-17 10:47:17 -07:00
committed by GitHub
parent 8b93e43037
commit 05cd7d92a8
11 changed files with 900 additions and 96 deletions
@@ -39,6 +39,7 @@ interface RegistryCommand extends GlobalCommand {
interface GlobalCommandsContextValue {
register: (commands: GlobalCommand[]) => () => void
invoke: (id: string) => boolean
}
const GlobalCommandsContext = createContext<GlobalCommandsContextValue | null>(null)
@@ -142,11 +143,39 @@ export function GlobalCommandsProvider({ children }: { children: ReactNode }) {
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
}, [isMac, router])
const value = useMemo<GlobalCommandsContextValue>(() => ({ register }), [register])
const invoke = useCallback((id: string): boolean => {
const cmd = registryRef.current.get(id)
if (!cmd) return false
try {
cmd.handler(new KeyboardEvent('keydown'))
} catch (err) {
logger.error('Global command handler threw', { id, err })
}
return true
}, [])
const value = useMemo<GlobalCommandsContextValue>(
() => ({ register, invoke }),
[register, invoke]
)
return <GlobalCommandsContext.Provider value={value}>{children}</GlobalCommandsContext.Provider>
}
/**
* Returns a function that runs a registered global command by id, mirroring its
* keyboard shortcut exactly. Returns `false` when no command with that id is
* currently registered (e.g. a workflow-only command invoked off-canvas), so
* callers can offer the action safely without knowing what is mounted.
*/
export function useInvokeGlobalCommand(): (id: string) => boolean {
const ctx = useContext(GlobalCommandsContext)
if (!ctx) {
throw new Error('useInvokeGlobalCommand must be used within GlobalCommandsProvider')
}
return ctx.invoke
}
export function useRegisterGlobalCommands(commands: GlobalCommand[] | (() => GlobalCommand[])) {
const ctx = useContext(GlobalCommandsContext)
if (!ctx) {
@@ -6,7 +6,54 @@ import { Command } from 'cmdk'
import { File, Workflow } from '@/components/emcn/icons'
import { cn } from '@/lib/core/utils/cn'
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 {
COMMAND_ITEM_CLASSNAME,
fuzzyMatch,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils'
interface Segment {
text: string
hit: boolean
}
function buildSegments(text: string, positions: readonly number[]): Segment[] {
const hits = new Set(positions)
const segments: Segment[] = []
for (let i = 0; i < text.length; i++) {
const hit = hits.has(i)
const last = segments[segments.length - 1]
if (last && last.hit === hit) last.text += text[i]
else segments.push({ text: text[i], hit })
}
return segments
}
/**
* Renders `text` with the characters that match `query` emphasized. Falls back
* to plain text when there is no query or no positional match against the
* display text (e.g. the row matched on a hidden id rather than its label).
*/
export const HighlightedText = memo(
function HighlightedText({ text, query }: { text: string; query?: string }) {
if (!query) return <>{text}</>
const { positions } = fuzzyMatch(text, query)
if (positions.length === 0) return <>{text}</>
return (
<>
{buildSegments(text, positions).map((segment, index) =>
segment.hit ? (
<span key={index} className='font-semibold text-[var(--text-body)]'>
{segment.text}
</span>
) : (
<span key={index}>{segment.text}</span>
)
)}
</>
)
},
(prev, next) => prev.text === next.text && prev.query === next.query
)
export const MemoizedCommandItem = memo(
function CommandItem({
@@ -15,7 +62,8 @@ export const MemoizedCommandItem = memo(
icon: Icon,
bgColor,
showColoredIcon,
children,
label,
query,
}: CommandItemProps) {
return (
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
@@ -32,7 +80,9 @@ export const MemoizedCommandItem = memo(
)}
/>
</div>
<span className='truncate text-[var(--text-body)]'>{children}</span>
<span className='truncate text-[var(--text-body)]'>
<HighlightedText text={label} query={query} />
</span>
</Command.Item>
)
},
@@ -41,7 +91,46 @@ export const MemoizedCommandItem = memo(
prev.icon === next.icon &&
prev.bgColor === next.bgColor &&
prev.showColoredIcon === next.showColoredIcon &&
prev.children === next.children
prev.label === next.label &&
prev.query === next.query
)
export const MemoizedActionItem = memo(
function ActionItem({
value,
onSelect,
icon: Icon,
name,
shortcut,
query,
}: {
value: string
onSelect: () => void
icon: ComponentType<{ className?: string }>
name: string
shortcut?: string
query?: string
}) {
return (
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
<Icon className='size-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='truncate text-[var(--text-body)]'>
<HighlightedText text={name} query={query} />
</span>
{shortcut && (
<span className='ml-auto flex-shrink-0 text-[var(--text-subtle)] text-small'>
{shortcut}
</span>
)}
</Command.Item>
)
},
(prev, next) =>
prev.value === next.value &&
prev.icon === next.icon &&
prev.name === next.name &&
prev.shortcut === next.shortcut &&
prev.query === next.query
)
export const MemoizedWorkflowItem = memo(
@@ -51,12 +140,14 @@ export const MemoizedWorkflowItem = memo(
name,
folderPath,
isCurrent,
query,
}: {
value: string
onSelect: () => void
name: string
folderPath?: string[]
isCurrent?: boolean
query?: string
}) {
return (
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
@@ -64,7 +155,9 @@ export const MemoizedWorkflowItem = memo(
<Workflow className='size-[14px] text-[var(--text-icon)]' />
</div>
<span className='flex min-w-0 max-w-[75%] flex-shrink-0 text-[var(--text-body)]'>
<span className='truncate'>{name}</span>
<span className='truncate'>
<HighlightedText text={name} query={query} />
</span>
{isCurrent && <span className='flex-shrink-0 whitespace-pre'> (current)</span>}
</span>
{folderPath && folderPath.length > 0 && (
@@ -87,6 +180,7 @@ export const MemoizedWorkflowItem = memo(
prev.value === next.value &&
prev.name === next.name &&
prev.isCurrent === next.isCurrent &&
prev.query === next.query &&
(prev.folderPath === next.folderPath ||
(prev.folderPath?.length === next.folderPath?.length &&
(prev.folderPath ?? []).every((segment, i) => segment === next.folderPath?.[i])))
@@ -98,11 +192,13 @@ export const MemoizedFileItem = memo(
onSelect,
name,
folderPath,
query,
}: {
value: string
onSelect: () => void
name: string
folderPath?: string[]
query?: string
}) {
return (
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
@@ -110,7 +206,9 @@ export const MemoizedFileItem = memo(
<File className='size-[14px] text-[var(--text-icon)]' />
</div>
<span className='flex min-w-0 max-w-[75%] flex-shrink-0 font-base text-[var(--text-body)]'>
<span className='truncate'>{name}</span>
<span className='truncate'>
<HighlightedText text={name} query={query} />
</span>
</span>
{folderPath && folderPath.length > 0 && (
<span className='ml-auto flex min-w-0 pl-2 font-base text-[var(--text-subtle)] text-small'>
@@ -131,6 +229,7 @@ export const MemoizedFileItem = memo(
(prev, next) =>
prev.value === next.value &&
prev.name === next.name &&
prev.query === next.query &&
(prev.folderPath === next.folderPath ||
(prev.folderPath?.length === next.folderPath?.length &&
(prev.folderPath ?? []).every((segment, i) => segment === next.folderPath?.[i])))
@@ -141,18 +240,22 @@ export const MemoizedTaskItem = memo(
value,
onSelect,
name,
query,
}: {
value: string
onSelect: () => void
name: string
query?: string
}) {
return (
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
<span className='truncate text-[var(--text-body)]'>{name}</span>
<span className='truncate text-[var(--text-body)]'>
<HighlightedText text={name} query={query} />
</span>
</Command.Item>
)
},
(prev, next) => prev.value === next.value && prev.name === next.name
(prev, next) => prev.value === next.value && prev.name === next.name && prev.query === next.query
)
export const MemoizedWorkspaceItem = memo(
@@ -161,23 +264,30 @@ export const MemoizedWorkspaceItem = memo(
onSelect,
name,
isCurrent,
query,
}: {
value: string
onSelect: () => void
name: string
isCurrent?: boolean
query?: string
}) {
return (
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
<span className='flex min-w-0 text-[var(--text-body)]'>
<span className='truncate'>{name}</span>
<span className='truncate'>
<HighlightedText text={name} query={query} />
</span>
{isCurrent && <span className='flex-shrink-0 whitespace-pre'> (current)</span>}
</span>
</Command.Item>
)
},
(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.query === next.query
)
export const MemoizedPageItem = memo(
@@ -187,17 +297,21 @@ export const MemoizedPageItem = memo(
icon: Icon,
name,
shortcut,
query,
}: {
value: string
onSelect: () => void
icon: ComponentType<{ className?: string }>
name: string
shortcut?: string
query?: string
}) {
return (
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
<Icon className='size-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='truncate text-[var(--text-body)]'>{name}</span>
<span className='truncate text-[var(--text-body)]'>
<HighlightedText text={name} query={query} />
</span>
{shortcut && (
<span className='ml-auto flex-shrink-0 text-[var(--text-subtle)] text-small'>
{shortcut}
@@ -210,7 +324,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.query === next.query
)
export const MemoizedIconItem = memo(
@@ -219,18 +334,26 @@ export const MemoizedIconItem = memo(
onSelect,
name,
icon: Icon,
query,
}: {
value: string
onSelect: () => void
name: string
icon: ComponentType<{ className?: string }>
query?: string
}) {
return (
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
<Icon className='size-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='truncate text-[var(--text-body)]'>{name}</span>
<span className='truncate text-[var(--text-body)]'>
<HighlightedText text={name} query={query} />
</span>
</Command.Item>
)
},
(prev, next) => prev.value === next.value && prev.name === next.name && prev.icon === next.icon
(prev, next) =>
prev.value === next.value &&
prev.name === next.name &&
prev.icon === next.icon &&
prev.query === next.query
)
@@ -1,4 +1,6 @@
export {
HighlightedText,
MemoizedActionItem,
MemoizedCommandItem,
MemoizedFileItem,
MemoizedIconItem,
@@ -1,4 +1,5 @@
export {
ActionsGroup,
BlocksGroup,
ChatsGroup,
ConnectedAccountsGroup,
@@ -5,6 +5,7 @@ import { memo } from 'react'
import { Command } from 'cmdk'
import { Database, Table } from '@/components/emcn/icons'
import {
MemoizedActionItem,
MemoizedCommandItem,
MemoizedFileItem,
MemoizedIconItem,
@@ -14,6 +15,7 @@ import {
MemoizedWorkspaceItem,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items'
import type {
ActionItem,
FileItem,
IntegrationSearchItem,
PageItem,
@@ -28,12 +30,41 @@ import type {
SearchToolOperationItem,
} from '@/stores/modals/search/types'
export const ActionsGroup = memo(function ActionsGroup({
items,
onSelect,
query,
}: {
items: ActionItem[]
onSelect: (action: ActionItem) => void
query?: string
}) {
if (items.length === 0) return null
return (
<Command.Group heading='Actions' className={GROUP_HEADING_CLASSNAME}>
{items.map((action) => (
<MemoizedActionItem
key={action.id}
value={`${action.name} ${action.keywords ?? ''} action-${action.id}`}
onSelect={() => onSelect(action)}
icon={action.icon}
name={action.name}
shortcut={action.shortcut}
query={query}
/>
))}
</Command.Group>
)
})
export const BlocksGroup = memo(function BlocksGroup({
items,
onSelect,
query,
}: {
items: SearchBlockItem[]
onSelect: (block: SearchBlockItem) => void
query?: string
}) {
if (items.length === 0) return null
return (
@@ -46,9 +77,9 @@ export const BlocksGroup = memo(function BlocksGroup({
icon={block.icon}
bgColor={block.bgColor}
showColoredIcon
>
{block.name}
</MemoizedCommandItem>
label={block.name}
query={query}
/>
))}
</Command.Group>
)
@@ -57,9 +88,11 @@ export const BlocksGroup = memo(function BlocksGroup({
export const ToolsGroup = memo(function ToolsGroup({
items,
onSelect,
query,
}: {
items: SearchBlockItem[]
onSelect: (tool: SearchBlockItem) => void
query?: string
}) {
if (items.length === 0) return null
return (
@@ -72,9 +105,9 @@ export const ToolsGroup = memo(function ToolsGroup({
icon={tool.icon}
bgColor={tool.bgColor}
showColoredIcon
>
{tool.name}
</MemoizedCommandItem>
label={tool.name}
query={query}
/>
))}
</Command.Group>
)
@@ -83,9 +116,11 @@ export const ToolsGroup = memo(function ToolsGroup({
export const TriggersGroup = memo(function TriggersGroup({
items,
onSelect,
query,
}: {
items: SearchBlockItem[]
onSelect: (trigger: SearchBlockItem) => void
query?: string
}) {
if (items.length === 0) return null
return (
@@ -98,9 +133,9 @@ export const TriggersGroup = memo(function TriggersGroup({
icon={trigger.icon}
bgColor={trigger.bgColor}
showColoredIcon
>
{trigger.name}
</MemoizedCommandItem>
label={trigger.name}
query={query}
/>
))}
</Command.Group>
)
@@ -109,9 +144,11 @@ export const TriggersGroup = memo(function TriggersGroup({
export const ToolOpsGroup = memo(function ToolOpsGroup({
items,
onSelect,
query,
}: {
items: SearchToolOperationItem[]
onSelect: (op: SearchToolOperationItem) => void
query?: string
}) {
if (items.length === 0) return null
return (
@@ -124,9 +161,9 @@ export const ToolOpsGroup = memo(function ToolOpsGroup({
icon={op.icon}
bgColor={op.bgColor}
showColoredIcon
>
{op.name}
</MemoizedCommandItem>
label={op.name}
query={query}
/>
))}
</Command.Group>
)
@@ -135,9 +172,11 @@ export const ToolOpsGroup = memo(function ToolOpsGroup({
export const DocsGroup = memo(function DocsGroup({
items,
onSelect,
query,
}: {
items: SearchDocItem[]
onSelect: (doc: SearchDocItem) => void
query?: string
}) {
if (items.length === 0) return null
return (
@@ -150,9 +189,9 @@ export const DocsGroup = memo(function DocsGroup({
icon={doc.icon}
bgColor='#6B7280'
showColoredIcon
>
{doc.name}
</MemoizedCommandItem>
label={doc.name}
query={query}
/>
))}
</Command.Group>
)
@@ -161,9 +200,11 @@ export const DocsGroup = memo(function DocsGroup({
export const WorkflowsGroup = memo(function WorkflowsGroup({
items,
onSelect,
query,
}: {
items: WorkflowItem[]
onSelect: (workflow: WorkflowItem) => void
query?: string
}) {
if (items.length === 0) return null
return (
@@ -176,6 +217,7 @@ export const WorkflowsGroup = memo(function WorkflowsGroup({
name={workflow.name}
folderPath={workflow.folderPath}
isCurrent={workflow.isCurrent}
query={query}
/>
))}
</Command.Group>
@@ -185,9 +227,11 @@ export const WorkflowsGroup = memo(function WorkflowsGroup({
export const ChatsGroup = memo(function ChatsGroup({
items,
onSelect,
query,
}: {
items: TaskItem[]
onSelect: (task: TaskItem) => void
query?: string
}) {
if (items.length === 0) return null
return (
@@ -198,6 +242,7 @@ export const ChatsGroup = memo(function ChatsGroup({
value={`${task.name} task-${task.id}`}
onSelect={() => onSelect(task)}
name={task.name}
query={query}
/>
))}
</Command.Group>
@@ -207,9 +252,11 @@ export const ChatsGroup = memo(function ChatsGroup({
export const WorkspacesGroup = memo(function WorkspacesGroup({
items,
onSelect,
query,
}: {
items: WorkspaceItem[]
onSelect: (workspace: WorkspaceItem) => void
query?: string
}) {
if (items.length === 0) return null
return (
@@ -221,6 +268,7 @@ export const WorkspacesGroup = memo(function WorkspacesGroup({
onSelect={() => onSelect(workspace)}
name={workspace.name}
isCurrent={workspace.isCurrent}
query={query}
/>
))}
</Command.Group>
@@ -230,9 +278,11 @@ export const WorkspacesGroup = memo(function WorkspacesGroup({
export const PagesGroup = memo(function PagesGroup({
items,
onSelect,
query,
}: {
items: PageItem[]
onSelect: (page: PageItem) => void
query?: string
}) {
if (items.length === 0) return null
return (
@@ -245,6 +295,7 @@ export const PagesGroup = memo(function PagesGroup({
icon={page.icon}
name={page.name}
shortcut={page.shortcut}
query={query}
/>
))}
</Command.Group>
@@ -260,9 +311,11 @@ export const IntegrationsGroup = createColoredIconGroup('Integrations', 'integra
export const FilesGroup = memo(function FilesGroup({
items,
onSelect,
query,
}: {
items: FileItem[]
onSelect: (file: FileItem) => void
query?: string
}) {
if (items.length === 0) return null
return (
@@ -274,6 +327,7 @@ export const FilesGroup = memo(function FilesGroup({
onSelect={() => onSelect(file)}
name={file.name}
folderPath={file.folderPath}
query={query}
/>
))}
</Command.Group>
@@ -290,9 +344,11 @@ function createColoredIconGroup(heading: string, prefix: string) {
return memo(function ColoredIconGroup({
items,
onSelect,
query,
}: {
items: IntegrationSearchItem[]
onSelect: (item: IntegrationSearchItem) => void
query?: string
}) {
if (items.length === 0) return null
return (
@@ -305,9 +361,9 @@ function createColoredIconGroup(heading: string, prefix: string) {
icon={item.icon}
bgColor={item.bgColor}
showColoredIcon
>
{item.name}
</MemoizedCommandItem>
label={item.name}
query={query}
/>
))}
</Command.Group>
)
@@ -322,9 +378,11 @@ function createIconGroup(
return memo(function IconGroup({
items,
onSelect,
query,
}: {
items: TaskItem[]
onSelect: (item: TaskItem) => void
query?: string
}) {
if (items.length === 0) return null
return (
@@ -336,6 +394,7 @@ function createIconGroup(
onSelect={() => onSelect(item)}
name={item.name}
icon={icon}
query={query}
/>
))}
</Command.Group>
@@ -1,25 +1,36 @@
'use client'
import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Command } from 'cmdk'
import { useParams, useRouter } from 'next/navigation'
import { useTheme } from 'next-themes'
import { usePostHog } from 'posthog-js/react'
import { createPortal } from 'react-dom'
import { Library } from '@/components/emcn'
import {
Calendar,
Database,
Expand,
File,
FolderPlus,
HelpCircle,
Home,
Integration,
Link,
Palette,
Play,
Plus,
Settings,
Table,
Upload,
Users,
} from '@/components/emcn/icons'
import { Search } from '@/components/emcn/icons/search'
import { cn } from '@/lib/core/utils/cn'
import { captureEvent } from '@/lib/posthog/client'
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,
@@ -34,6 +45,7 @@ import type {
SearchToolOperationItem,
} from '@/stores/modals/search/types'
import {
ActionsGroup,
BlocksGroup,
ChatsGroup,
ConnectedAccountsGroup,
@@ -50,6 +62,7 @@ import {
WorkspacesGroup,
} from './components/search-groups'
import type {
ActionItem,
FileItem,
IntegrationSearchItem,
PageItem,
@@ -60,6 +73,8 @@ import type {
} from './utils'
import { filterAndSort } from './utils'
const logger = createLogger('SearchModal')
export type { SearchModalProps } from './utils'
export function SearchModal({
@@ -75,6 +90,10 @@ export function SearchModal({
connectedAccounts = [],
isOnWorkflowPage = false,
isOnIntegrationsPage = false,
canEdit = false,
onCreateWorkflow,
onCreateFolder,
onImportWorkflow,
}: SearchModalProps) {
const params = useParams()
const router = useRouter()
@@ -83,6 +102,8 @@ export function SearchModal({
const [mounted, setMounted] = useState(false)
const { navigateToSettings } = useSettingsNavigation()
const { config: permissionConfig } = usePermissionConfig()
const { resolvedTheme, setTheme } = useTheme()
const invokeCommand = useInvokeGlobalCommand()
const posthog = usePostHog()
const routerRef = useRef(router)
@@ -178,6 +199,100 @@ export function SearchModal({
]
)
/**
* Verbs the palette can run directly. Entity navigation lives in the groups
* below; this list is for "do something" intents (create, import, toggle).
*/
const actions = useMemo((): ActionItem[] => {
const list: ActionItem[] = []
list.push({
id: 'run-workflow',
name: 'Run workflow',
keywords: 'execute start play test',
icon: Play,
shortcut: '⌘↵',
context: 'workflow',
run: () => invokeCommand('run-workflow'),
})
if (canEdit && onCreateWorkflow) {
list.push({
id: 'create-workflow',
name: 'Create workflow',
keywords: 'new add build',
icon: Plus,
context: 'global',
run: onCreateWorkflow,
})
}
if (canEdit && onCreateFolder) {
list.push({
id: 'create-folder',
name: 'Create folder',
keywords: 'new add group',
icon: FolderPlus,
context: 'global',
run: onCreateFolder,
})
}
if (canEdit && onImportWorkflow) {
list.push({
id: 'import-workflow',
name: 'Import workflow',
keywords: 'upload add',
icon: Upload,
context: 'global',
run: onImportWorkflow,
})
}
list.push({
id: 'fit-to-view',
name: 'Fit workflow to view',
keywords: 'zoom center recenter canvas reset',
icon: Expand,
shortcut: '⌘⇧F',
context: 'workflow',
run: () => invokeCommand('fit-to-view'),
})
list.push({
id: 'copy-workflow-url',
name: 'Copy workflow link',
keywords: 'url share clipboard',
icon: Link,
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',
keywords: 'members people add user organization',
icon: Users,
context: 'global',
run: () => navigateToSettings({ section: 'teammates' }),
})
list.push({
id: 'toggle-theme',
name: 'Toggle theme',
keywords: 'dark light mode appearance color',
icon: Palette,
context: 'global',
run: () => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark'),
})
return list
}, [
canEdit,
onCreateWorkflow,
onCreateFolder,
onImportWorkflow,
invokeCommand,
navigateToSettings,
resolvedTheme,
setTheme,
])
const [search, setSearch] = useState('')
const [prevOpen, setPrevOpen] = useState(open)
if (open !== prevOpen) {
@@ -410,6 +525,20 @@ export function SearchModal({
[workspaceId]
)
const handleActionSelect = useCallback(
(item: ActionItem) => {
onOpenChangeRef.current(false)
item.run()
captureEvent(posthogRef.current, 'search_result_selected', {
result_type: 'action',
action_id: item.id,
query_length: deferredSearchRef.current.length,
workspace_id: workspaceId,
})
},
[workspaceId]
)
const handleBlockSelectAsBlock = useCallback(
(block: SearchBlockItem) => handleBlockSelect(block, 'block'),
[handleBlockSelect]
@@ -429,85 +558,88 @@ export function SearchModal({
onOpenChangeRef.current(false)
}, [])
const filteredActions = 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])
/**
* Ranking matches against clean, human-meaningful text only (names, types,
* aliases, folder paths) — never the structural `<type>-<id>`/uuid tokens used
* for cmdk row identity. Those tokens carry letters (e.g. "block", "tool") that
* would otherwise let short fuzzy queries scatter-match unrelated items.
*/
const filteredBlocks = useMemo(() => {
if (!isOnWorkflowPage) return []
return filterAndSort(blocks, (b) => b.searchValue ?? `${b.name} block-${b.id}`, deferredSearch)
return filterAndSort(blocks, (b) => b.searchValue ?? b.name, deferredSearch)
}, [isOnWorkflowPage, blocks, deferredSearch])
const filteredTools = useMemo(() => {
if (!isOnWorkflowPage) return []
return filterAndSort(tools, (t) => t.searchValue ?? `${t.name} tool-${t.id}`, deferredSearch)
return filterAndSort(tools, (t) => t.searchValue ?? t.name, deferredSearch)
}, [isOnWorkflowPage, tools, deferredSearch])
const filteredTriggers = useMemo(() => {
if (!isOnWorkflowPage) return []
return filterAndSort(triggers, (t) => `${t.name} trigger-${t.id}`, deferredSearch)
return filterAndSort(triggers, (t) => `${t.name} ${t.id}`, deferredSearch)
}, [isOnWorkflowPage, triggers, deferredSearch])
const filteredToolOps = useMemo(() => {
if (!isOnWorkflowPage) return []
return filterAndSort(
toolOperations,
(op) => `${op.searchValue} operation-${op.id}`,
deferredSearch
)
return filterAndSort(toolOperations, (op) => op.searchValue, deferredSearch)
}, [isOnWorkflowPage, toolOperations, deferredSearch])
const filteredDocs = useMemo(() => {
if (!isOnWorkflowPage) return []
return filterAndSort(docs, (d) => `${d.name} docs documentation doc-${d.id}`, deferredSearch)
return filterAndSort(docs, (d) => `${d.name} docs documentation`, deferredSearch)
}, [isOnWorkflowPage, docs, deferredSearch])
const filteredTables = useMemo(
() => filterAndSort(tables, (t) => `${t.name} table-${t.id}`, deferredSearch),
() => filterAndSort(tables, (t) => t.name, deferredSearch),
[tables, deferredSearch]
)
const filteredFiles = useMemo(
() =>
filterAndSort(
files,
(f) => `${f.name} ${f.folderPath?.join(' / ') ?? ''} file-${f.id}`,
deferredSearch
),
() => filterAndSort(files, (f) => `${f.name} ${f.folderPath?.join(' ') ?? ''}`, deferredSearch),
[files, deferredSearch]
)
const filteredKnowledgeBases = useMemo(
() =>
filterAndSort(knowledgeBases, (kb) => `${kb.name} knowledge-base-${kb.id}`, deferredSearch),
() => filterAndSort(knowledgeBases, (kb) => kb.name, deferredSearch),
[knowledgeBases, deferredSearch]
)
const filteredWorkflows = useMemo(
() => filterAndSort(workflows, (w) => `${w.name} workflow-${w.id}`, deferredSearch),
() =>
filterAndSort(workflows, (w) => `${w.name} ${w.folderPath?.join(' ') ?? ''}`, deferredSearch),
[workflows, deferredSearch]
)
const filteredChats = useMemo(
() => filterAndSort(chats, (t) => `${t.name} task-${t.id}`, deferredSearch),
() => filterAndSort(chats, (t) => t.name, deferredSearch),
[chats, deferredSearch]
)
const filteredWorkspaces = useMemo(
() => filterAndSort(workspaces, (w) => `${w.name} workspace-${w.id}`, deferredSearch),
() => filterAndSort(workspaces, (w) => w.name, deferredSearch),
[workspaces, deferredSearch]
)
const filteredPages = useMemo(
() => filterAndSort(pages, (p) => `${p.name} page-${p.id}`, deferredSearch),
() => filterAndSort(pages, (p) => p.name, deferredSearch),
[pages, deferredSearch]
)
/** Connected accounts: visible on the integrations page even with empty input. */
const filteredConnectedAccounts = useMemo(() => {
if (!isOnIntegrationsPage) return []
return filterAndSort(
connectedAccounts,
(a) => `${a.name} connected-account-${a.id}`,
deferredSearch
)
return filterAndSort(connectedAccounts, (a) => a.name, deferredSearch)
}, [isOnIntegrationsPage, connectedAccounts, deferredSearch])
/** Catalog integrations: only shown once the user has typed something. */
const filteredIntegrations = useMemo(() => {
if (!isOnIntegrationsPage || !deferredSearch) return []
return filterAndSort(integrations, (i) => `${i.name} integration-${i.id}`, deferredSearch)
return filterAndSort(integrations, (i) => i.name, deferredSearch)
}, [isOnIntegrationsPage, deferredSearch, integrations])
if (!mounted) return null
@@ -561,23 +693,77 @@ export function SearchModal({
No results found.
</Command.Empty>
<ActionsGroup
items={filteredActions}
onSelect={handleActionSelect}
query={deferredSearch}
/>
<ConnectedAccountsGroup
items={filteredConnectedAccounts}
onSelect={handleConnectedAccountSelect}
query={deferredSearch}
/>
<IntegrationsGroup
items={filteredIntegrations}
onSelect={handleIntegrationSelect}
query={deferredSearch}
/>
<BlocksGroup
items={filteredBlocks}
onSelect={handleBlockSelectAsBlock}
query={deferredSearch}
/>
<ToolsGroup
items={filteredTools}
onSelect={handleBlockSelectAsTool}
query={deferredSearch}
/>
<TriggersGroup
items={filteredTriggers}
onSelect={handleBlockSelectAsTrigger}
query={deferredSearch}
/>
<ChatsGroup
items={filteredChats}
onSelect={handleChatSelect}
query={deferredSearch}
/>
<WorkflowsGroup
items={filteredWorkflows}
onSelect={handleWorkflowSelect}
query={deferredSearch}
/>
<TablesGroup
items={filteredTables}
onSelect={handleTableSelect}
query={deferredSearch}
/>
<FilesGroup
items={filteredFiles}
onSelect={handleFileSelect}
query={deferredSearch}
/>
<KnowledgeBasesGroup
items={filteredKnowledgeBases}
onSelect={handleKbSelect}
query={deferredSearch}
/>
<ToolOpsGroup
items={filteredToolOps}
onSelect={handleToolOperationSelect}
query={deferredSearch}
/>
<WorkspacesGroup
items={filteredWorkspaces}
onSelect={handleWorkspaceSelect}
query={deferredSearch}
/>
<DocsGroup items={filteredDocs} onSelect={handleDocSelect} query={deferredSearch} />
<PagesGroup
items={filteredPages}
onSelect={handlePageSelect}
query={deferredSearch}
/>
<IntegrationsGroup items={filteredIntegrations} onSelect={handleIntegrationSelect} />
<BlocksGroup items={filteredBlocks} onSelect={handleBlockSelectAsBlock} />
<ToolsGroup items={filteredTools} onSelect={handleBlockSelectAsTool} />
<TriggersGroup items={filteredTriggers} onSelect={handleBlockSelectAsTrigger} />
<ChatsGroup items={filteredChats} onSelect={handleChatSelect} />
<WorkflowsGroup items={filteredWorkflows} onSelect={handleWorkflowSelect} />
<TablesGroup items={filteredTables} onSelect={handleTableSelect} />
<FilesGroup items={filteredFiles} onSelect={handleFileSelect} />
<KnowledgeBasesGroup items={filteredKnowledgeBases} onSelect={handleKbSelect} />
<ToolOpsGroup items={filteredToolOps} onSelect={handleToolOperationSelect} />
<WorkspacesGroup items={filteredWorkspaces} onSelect={handleWorkspaceSelect} />
<DocsGroup items={filteredDocs} onSelect={handleDocSelect} />
<PagesGroup items={filteredPages} onSelect={handlePageSelect} />
</Command.List>
</Command>
</div>
@@ -0,0 +1,243 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { filterAndSort, fuzzyMatch } from './utils'
/**
* The matcher that shipped before fuzzy matching was introduced. Re-implemented
* here verbatim so the new matcher can be proven a strict superset: anything the
* old matcher returned, the new one must still return. This is the core
* no-regression guarantee.
*/
function oldScoreMatch(value: string, search: string): number {
if (!search) return 1
const v = value.toLowerCase()
const s = search.toLowerCase()
if (v === s) return 1
if (v.startsWith(s)) return 0.9
if (v.includes(s)) return 0.7
const words = s.split(/\s+/).filter(Boolean)
if (words.length > 1 && words.every((w) => v.includes(w))) return 0.5
return 0
}
function oldFilterAndSort<T>(items: T[], toValue: (item: T) => string, search: string): T[] {
if (!search) return items
const scored: [T, number][] = []
for (const item of items) {
const score = oldScoreMatch(toValue(item), search)
if (score > 0) scored.push([item, score])
}
scored.sort((a, b) => b[1] - a[1])
return scored.map(([item]) => item)
}
interface Entry {
label: string
/** The string the modal actually searches against (name + type/id junk). */
value: string
}
/** Mirrors how groups build their `value` strings (name + slug/id suffix). */
function block(label: string, slug: string): Entry {
return { label, value: `${label} ${slug} block-${slug}` }
}
function workflow(label: string, folder: string): Entry {
return { label, value: `${label} ${folder} workflow-${slugUuid(label)}` }
}
function action(label: string, keywords: string): Entry {
const id = label.toLowerCase().replace(/\s+/g, '-')
return { label, value: `${label} ${keywords} action-${id}` }
}
function slugUuid(label: string): string {
return `${label.toLowerCase().replace(/\s+/g, '')}-9f2a3b4c5d6e`
}
const CORPUS: Entry[] = [
block('Slack', 'slack'),
block('Gmail', 'gmail'),
block('Google Sheets', 'google_sheets'),
block('Google PageSpeed', 'google_pagespeed'),
block('GitHub', 'github'),
block('Notion', 'notion'),
block('Postgres', 'postgresql'),
block('OpenAI', 'openai'),
block('Airtable', 'airtable'),
block('HubSpot', 'hubspot'),
block('Linear', 'linear'),
block('Discord', 'discord'),
block('Microsoft Teams', 'microsoft_teams'),
block('Webhook', 'webhook'),
block('Schedule', 'schedule'),
block('Agent', 'agent'),
block('Function', 'function'),
block('Condition', 'condition'),
block('Router', 'router'),
block('Knowledge Base', 'knowledge'),
workflow('Customer Onboarding Flow', 'Sales'),
workflow('Daily Report', 'Ops'),
workflow('Lead Enrichment', 'Sales'),
action('Create workflow', 'new add build'),
action('Create folder', 'new add group'),
action('Import workflow', 'upload add'),
action('Toggle theme', 'dark light mode appearance color'),
]
const toValue = (e: Entry) => e.value
/**
* A broad sweep of realistic query shapes, grouped by intent: single chars,
* exact-ish names, prefixes, contains/mid-word, multi-word, initialisms and
* scattered (the new wins), typos, and genuine non-matches.
*/
const QUERIES = [
's',
'g',
'a',
'w',
'slack',
'gmail',
'github',
'notion',
'postgres',
'openai',
'agent',
'goog',
'micro',
'know',
'cond',
'rout',
'sched',
'hook',
'table',
'spot',
'mail',
'google sheets',
'sheets google',
'create workflow',
'workflow create',
'customer onboarding',
'slk',
'gps',
'msteams',
'crwf',
'cwf',
'kb',
'githb',
'postgrs',
'zzz',
'qqqq',
]
describe('fuzzyMatch / filterAndSort — no regression vs. old matcher', () => {
it('returns a strict superset of the old matcher for every query (never loses a result)', () => {
for (const query of QUERIES) {
const oldLabels = new Set(oldFilterAndSort(CORPUS, toValue, query).map((e) => e.label))
const newLabels = new Set(filterAndSort(CORPUS, toValue, query).map((e) => e.label))
for (const label of oldLabels) {
expect(
newLabels.has(label),
`query "${query}": new matcher dropped "${label}" that the old matcher returned`
).toBe(true)
}
}
})
it('preserves the old #1 result for exact/prefix/contains queries (no top-rank regression)', () => {
const exactish = [
'slack',
'gmail',
'github',
'notion',
'postgres',
'openai',
'agent',
'goog',
'micro',
'know',
'cond',
'rout',
'sched',
]
for (const query of exactish) {
const oldTop = oldFilterAndSort(CORPUS, toValue, query)[0]
const newTop = filterAndSort(CORPUS, toValue, query)[0]
if (oldTop) {
expect(newTop?.label, `query "${query}" top result changed`).toBe(oldTop.label)
}
}
})
})
describe('fuzzyMatch — new wins (initialisms & scattered)', () => {
const wins: Array<[string, string]> = [
['slk', 'Slack'],
['gps', 'Google PageSpeed'],
['crwf', 'Create workflow'],
['msteams', 'Microsoft Teams'],
]
for (const [query, expectedTop] of wins) {
it(`"${query}" surfaces "${expectedTop}" as the top result`, () => {
const results = filterAndSort(CORPUS, toValue, query)
expect(results[0]?.label).toBe(expectedTop)
})
}
it('finds initialisms the old matcher missed entirely (old returns 0 for "slk")', () => {
expect(oldFilterAndSort(CORPUS, toValue, 'slk')).toHaveLength(0)
expect(filterAndSort(CORPUS, toValue, 'slk').map((e) => e.label)).toContain('Slack')
})
})
describe('fuzzyMatch — noise control', () => {
it('rejects a mid-word scattered subsequence ("oge" in P-o-st-g-r-e-s is not a substring)', () => {
expect(fuzzyMatch('Postgres', 'oge').matched).toBe(false)
})
it('ranks every "g"-prefixed result above results that only contain "g" deeper', () => {
const results = filterAndSort(CORPUS, toValue, 'g')
const labels = results.map((e) => e.label)
const firstNonPrefix = labels.findIndex((l) => !l.toLowerCase().startsWith('g'))
const lastPrefix = labels.reduce((acc, l, i) => (l.toLowerCase().startsWith('g') ? i : acc), -1)
if (firstNonPrefix !== -1 && lastPrefix !== -1) {
expect(lastPrefix).toBeLessThan(firstNonPrefix)
}
})
it('returns no matches for genuine non-matches', () => {
expect(filterAndSort(CORPUS, toValue, 'zzz')).toHaveLength(0)
expect(filterAndSort(CORPUS, toValue, 'qqqq')).toHaveLength(0)
})
})
describe('fuzzyMatch — positions for highlighting', () => {
it('reports prefix match positions', () => {
expect(fuzzyMatch('Slack', 'sla').positions).toEqual([0, 1, 2])
})
it('reports scattered match positions for "slk" against "Slack" (S, l, k)', () => {
expect(fuzzyMatch('Slack', 'slk').positions).toEqual([0, 1, 4])
})
it('highlights the substring itself, not an earlier scattered occurrence', () => {
const result = fuzzyMatch('a_apple', 'apple')
expect(result.matched).toBe(true)
expect(result.positions).toEqual([2, 3, 4, 5, 6])
})
it('highlights a mid-string substring at its real position', () => {
expect(fuzzyMatch('Webhook', 'hook').positions).toEqual([3, 4, 5, 6])
})
it('reports empty positions for empty query', () => {
const result = fuzzyMatch('Slack', '')
expect(result.matched).toBe(true)
expect(result.positions).toEqual([])
})
it('matches multi-word tokens order-independently', () => {
const result = fuzzyMatch('Slack Send Message', 'message slack')
expect(result.matched).toBe(true)
})
})
@@ -1,4 +1,4 @@
import type { ComponentType, ReactNode } from 'react'
import type { ComponentType } from 'react'
export interface IntegrationSearchItem {
id: string
@@ -46,6 +46,26 @@ export interface FileItem {
folderPath?: string[]
}
/** Where an {@link ActionItem} (a verb) is available. */
export type ActionContext = 'global' | 'workflow' | 'integrations'
/**
* An action is a verb the palette can run directly (create, import, toggle),
* as opposed to an entity the user navigates to. Actions render at the top of
* the result list so the most common "do something" intents are one keystroke
* away.
*/
export interface ActionItem {
id: string
name: string
/** Extra terms folded into the search value (e.g. "new add"). */
keywords?: string
icon: ComponentType<{ className?: string }>
shortcut?: string
context: ActionContext
run: () => void
}
export interface SearchModalProps {
open: boolean
onOpenChange: (open: boolean) => void
@@ -59,6 +79,10 @@ export interface SearchModalProps {
connectedAccounts?: IntegrationSearchItem[]
isOnWorkflowPage?: boolean
isOnIntegrationsPage?: boolean
canEdit?: boolean
onCreateWorkflow?: () => void
onCreateFolder?: () => void
onImportWorkflow?: () => void
}
export interface CommandItemProps {
@@ -67,7 +91,10 @@ export interface CommandItemProps {
icon: ComponentType<{ className?: string }>
bgColor: string
showColoredIcon?: boolean
children: ReactNode
/** Primary text. Matched characters are highlighted against {@link query}. */
label: string
/** Active search query, used to bold matched characters. */
query?: string
}
export const GROUP_HEADING_CLASSNAME =
@@ -76,30 +103,157 @@ export const GROUP_HEADING_CLASSNAME =
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'
function scoreMatch(value: string, search: string): number {
if (!search) return 1
const valueLower = value.toLowerCase()
const searchLower = search.toLowerCase()
/** Characters that begin a new word — a match here scores higher. */
const SEPARATORS = new Set([' ', '-', '_', '/', '.', ':', '(', ')'])
if (valueLower === searchLower) return 1
if (valueLower.startsWith(searchLower)) return 0.9
if (valueLower.includes(searchLower)) return 0.7
const words = searchLower.split(/\s+/).filter(Boolean)
if (words.length > 1) {
if (words.every((w) => valueLower.includes(w))) return 0.5
}
return 0
/** Result of matching a query against a single candidate string. */
export interface FuzzyResult {
/** Whether every query character was found, in order. */
matched: boolean
/** Relative ranking score; higher sorts first. Only meaningful when matched. */
score: number
/** Indices into the candidate string that matched, ascending. Read-only. */
positions: readonly number[]
}
/**
* Shared singleton for the no-match case. The frozen empty array makes the
* read-only contract explicit and guarantees the shared instance can never be
* mutated by a caller.
*/
const NO_MATCH: FuzzyResult = { matched: false, score: 0, positions: Object.freeze([]) }
function isCamelBoundary(text: string, index: number): boolean {
if (index === 0) return false
const prev = text[index - 1]
const curr = text[index]
return prev === prev.toLowerCase() && curr !== curr.toLowerCase() && curr === curr.toUpperCase()
}
/**
* A "hard" boundary: the start of the string or immediately after a separator.
* Used to anchor scattered matches. Deliberately excludes camelCase so a fuzzy
* match cannot *start* in the middle of a word (e.g. the `S` in "PageSpeed"),
* which would let short queries scatter-match unrelated items. Interior
* camelCase still earns a scoring bonus — it just cannot anchor a match.
*/
function isHardBoundary(lowerText: string, index: number): boolean {
return index === 0 || SEPARATORS.has(lowerText[index - 1])
}
/**
* Order-independent fallback: a multi-word query matches when every token
* appears somewhere in the text. Preserves the original matcher's multi-word
* behavior (`message slack` → "Slack Send Message"). Single-word queries that
* reach here did not match as exact/prefix/contains and are rejected, so this
* never broadens single-token matching beyond the original behavior.
*/
function tokenFallback(lowerText: string, lowerQuery: string): FuzzyResult {
const tokens = lowerQuery.split(/\s+/).filter(Boolean)
if (tokens.length <= 1 || !tokens.every((token) => lowerText.includes(token))) return NO_MATCH
const tokenPositions = new Set<number>()
for (const token of tokens) {
const start = lowerText.indexOf(token)
for (let k = 0; k < token.length; k++) tokenPositions.add(start + k)
}
return {
matched: true,
score: 10 - lowerText.length * 0.1,
positions: Array.from(tokenPositions).sort((a, b) => a - b),
}
}
/**
* Subsequence fuzzy match with positional scoring. Rewards matches at word
* boundaries (`slk` → **S**lack), consecutive runs, and prefix/exact hits,
* while still matching scattered characters so typos and partial recall work.
*
* Exact, prefix, contains, and multi-word token matches all reproduce the
* original substring matcher's behavior, making this a strict superset: any
* result the old matcher returned, this one returns too. The only additions are
* scattered subsequences, and those are accepted only when the match STARTS at a
* hard word boundary — so initialisms match (`slk` → **S**la**c**k) but loose
* noise does not (`slack` will not scatter-match "Page**S**peed", and `se` will
* not match every item containing s…e).
*
* Falls back to order-independent token matching for multi-word queries
* (`message slack` matches "Slack Send Message") which a strict left-to-right
* subsequence would miss.
*
* Contiguous substring matches report the indices of the substring itself, so
* highlighting always bolds the run the user actually matched rather than an
* earlier scattered occurrence of the same characters.
*/
export function fuzzyMatch(text: string, query: string): FuzzyResult {
if (!query) return { matched: true, score: 1, positions: [] }
if (!text) return NO_MATCH
const lowerText = text.toLowerCase()
const lowerQuery = query.toLowerCase()
const substringIndex = lowerText.indexOf(lowerQuery)
if (substringIndex !== -1) {
const length = lowerQuery.length
const positions = Array.from({ length }, (_, k) => substringIndex + k)
let score = 1
if (substringIndex === 0) score += 10
else if (SEPARATORS.has(lowerText[substringIndex - 1])) score += 8
else if (isCamelBoundary(text, substringIndex)) score += 6
score += (length - 1) * 6
if (lowerText === lowerQuery) score += 120
else if (substringIndex === 0) score += 50
else score += 25
score -= substringIndex * 0.5
score -= (length - 1) * 0.15
score -= lowerText.length * 0.1
return { matched: true, score, positions }
}
const positions: number[] = []
let queryIndex = 0
let score = 0
let prevMatch = -2
for (let i = 0; i < lowerText.length && queryIndex < lowerQuery.length; i++) {
if (lowerText[i] !== lowerQuery[queryIndex]) continue
let charScore = 1
if (i === 0) charScore += 10
else if (SEPARATORS.has(lowerText[i - 1])) charScore += 8
else if (isCamelBoundary(text, i)) charScore += 6
if (prevMatch === i - 1) charScore += 5
score += charScore
positions.push(i)
prevMatch = i
queryIndex++
}
if (queryIndex === lowerQuery.length && isHardBoundary(lowerText, positions[0])) {
score -= positions[0] * 0.5
score -= (positions[positions.length - 1] - positions[0]) * 0.15
score -= lowerText.length * 0.1
return { matched: true, score, positions }
}
return tokenFallback(lowerText, lowerQuery)
}
/**
* Filters items whose value fuzzy-matches the search, ordered by descending
* score. Returns the input untouched when the search is empty.
*/
export function filterAndSort<T>(items: T[], toValue: (item: T) => string, search: string): T[] {
if (!search) return items
const scored: [T, number][] = []
const scored: Array<{ item: T; score: number }> = []
for (const item of items) {
const s = scoreMatch(toValue(item), search)
if (s > 0) scored.push([item, s])
const { matched, score } = fuzzyMatch(toValue(item), search)
if (matched) scored.push({ item, score })
}
scored.sort((a, b) => b[1] - a[1])
return scored.map(([item]) => item)
scored.sort((a, b) => b.score - a.score)
return scored.map((entry) => entry.item)
}
@@ -1735,6 +1735,10 @@ export const Sidebar = memo(function Sidebar() {
connectedAccounts={searchModalConnectedAccounts}
isOnWorkflowPage={!!workflowId}
isOnIntegrationsPage={isOnIntegrationsPage}
canEdit={canEdit}
onCreateWorkflow={handleCreateWorkflow}
onCreateFolder={handleCreateFolder}
onImportWorkflow={handleImportWorkflow}
/>
<HelpModal
+3
View File
@@ -569,8 +569,11 @@ export interface PostHogEventMap {
| 'docs'
| 'connected_account'
| 'integration'
| 'action'
query_length: number
workspace_id: string
/** Present when `result_type` is `action`; the id of the action that ran. */
action_id?: string
}
/** A home-page suggested action was clicked. `action_id` is the candidate id (e.g. `gmail-0`). */
+1 -1
View File
@@ -98,7 +98,7 @@ export const useSearchModalStore = create<SearchModalState>()(
icon: block.icon,
bgColor: block.bgColor || '#6B7280',
type: block.type,
searchValue: `${block.name} ${block.type} block-${block.type} ${buildCommandSearchableOptionSearchValue(block)}`,
searchValue: `${block.name} ${block.type} ${buildCommandSearchableOptionSearchValue(block)}`,
}
if (block.category === 'blocks' && block.type !== 'starter') {