mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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) {
|
||||
|
||||
+137
-14
@@ -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
|
||||
)
|
||||
|
||||
+2
@@ -1,4 +1,6 @@
|
||||
export {
|
||||
HighlightedText,
|
||||
MemoizedActionItem,
|
||||
MemoizedCommandItem,
|
||||
MemoizedFileItem,
|
||||
MemoizedIconItem,
|
||||
|
||||
+1
@@ -1,4 +1,5 @@
|
||||
export {
|
||||
ActionsGroup,
|
||||
BlocksGroup,
|
||||
ChatsGroup,
|
||||
ConnectedAccountsGroup,
|
||||
|
||||
+77
-18
@@ -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>
|
||||
|
||||
+227
-41
@@ -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>
|
||||
|
||||
+243
@@ -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)
|
||||
})
|
||||
})
|
||||
+175
-21
@@ -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
|
||||
|
||||
@@ -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`). */
|
||||
|
||||
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user