feat(search): page-aware cmd+k palette with ask-Sim mode (#6518)

* fix(chat): stop losing sends aborted during mount-settling

* fix(chat): detect aborts by signal state, not error identity

fetch rejects with the RAW abort reason when its signal carries one —
abort('unmount:client_cleanup') surfaces as a plain string, so every
err.name === 'AbortError' check missed it and the restore path never ran
(verified live). The test stub now rejects with the raw reason like real
fetch, which turns this gap red.

* fix(chat): hand an aborted chatless send to the next mount

The mount-settling cycle is a full remount — the pending chat key is
regenerated per instance, so restoring the aborted send into the dead
instance's queue orphaned it (verified live). A chatless send now
re-persists as a one-shot MothershipHandoffStorage handoff the next
mount's consumer re-sends; chat-bound sends keep the queue restore.

* fix(chat): deliver an aborted chatless send to the live replacement surface

The settling remount's consumer checks handoff storage before the
restore microtask re-persists it, so the stored handoff sat unread until
a navigation. The replacement surface's send listener IS registered by
restore time — deliver the message directly through the claimable send
event, keeping the stored handoff as the no-surface fallback.

* refactor(chat): thread the recoverable-abort outcome through the send result

Replaces the restorableCleanupAbortRef reset choreography with a widened
startSendMessage return ('recoverable_cleanup_abort'), so the restore
decision is ordinary data flow and the second caller cannot leave a stale
flag behind.

* fix(chat): carry attachments through the cross-mount send handoff

The recoverable-abort delivery excluded attachment-bearing sends, so
they restored under the dead instance's pending key and were silently
lost. The claimable send event now carries fileAttachments end to end
(dispatcher, home listener, restore path); only the storage fallback —
whose shape cannot hold attachments — still queue-restores them.

* fix(panel): forward event attachments to the copilot send

* fix(chat): carry attachments through the stored handoff lane too

The unclaimed-event fallback excluded attachment sends and restored
them under the disposed mount's pending key. The persisted handoff now
carries fileAttachments (they are plain references to already-uploaded
files), the home consumer forwards them, and the recovery branch always
hands off — no stranded lane remains.

* feat(search): improve command palette results

* feat(search): sharpen command palette discovery

Unify command surfaces, flatten ranked results, and remove favorites so the palette stays focused on fast discovery. Add Tab result cycling and workspace identity icons for quicker keyboard navigation.

* feat(search): unify cmd+k into one page-aware section model

Every palette view now derives from a single rule: the page's action group,
its own entity section hoisted, Platform actions, then a fixed tail shared by
all pages. Adds page commands for table/file/KB details, logs, and deploy; a
Logs section with run dates; chat last-activity receipts; kebab-cased
secondary search text with per-entry scattered matching; exact-section-name
ranking lifts; and scroll/selection fixes on open, loop, and arrow
navigation. Removes the canvas block/tool/trigger/docs sections and the
store's unused section restriction and pending-connect plumbing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(search): ask-Sim tab mode, canvas sections, and palette refinements

Tab now flips the palette into ask mode — Enter lands on Chat with the query
seeded via the proven curated-prompt handoff (auto-send deferred on a
diagnosed use-chat mount-abort bug) — replacing the no-results New Chat
fallback. Restores the canvas Blocks/Triggers/Tools/Tool operations sections
between the workflow Actions group and Sim, keeps the integrations catalog
and connected accounts off the canvas, renames the global group to Sim and
page groups to Actions, puts an exactly-named page above its lifted contents,
adds chat last-activity receipts, and softens the list chrome (hidden
scrollbar, shorter fade, scroll-margin fixes for arrow and loop navigation).
Also renames the generic webhook block to Webhook.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(search): order module results below Sim actions

* fix(review): make command palette fast and focused

* fix(search): restore Ask Sim prefill and refine palette ranking

* chore(sidebar): drop unused getSettingsHref destructure

* fix(deploy): gate every deploy invoker on full eligibility

* fix(search): port the palette to post-#6458 staging

Carries the former merge resolutions as one commit: the emcn icon set
(SelectAll fit-to-view, Search chrome), native browser-panel occlusion
gating, scheduled-tasks retirement, the chip-aware handoff consumer
superseding the ?handoff=1 machinery, and Knowledge bases pluralization.

* feat(search): auto-send Ask Sim queries via the chat handoff

* revert(search): return Ask Sim to prefill, storing raw prose

Auto-send still loses the message on cross-route navigation — use-chat's
cleanup abort fires during Home's mount-settling effect cycle. Prefill
restored, but via LandingPromptStorage directly so free-form queries are
never mentionified into @ chips.

* feat(search): auto-send Ask Sim queries via the chat handoff

Re-lands the auto-send flip: with fix/mship-mount-send-loss beneath this
branch, sends started during Home's mount-settling window survive the
cleanup abort (queued, restored, re-dispatched), so the handoff no longer
loses the query on cross-route navigation.

* fix(deploy): include registry loading in the deploy invoker gate

* improvement(search): label the ask row New Chat

* refactor(search): apply simplify-pass cleanups

- rank against a deferred query and hoist search-independent derivations
  so typing never blocks on the cross-section re-rank
- cache secondary-text tokenization (hottest per-keystroke loop)
- skip building browse groups mid-search; gate the palette-only
  credentials and logs queries on the palette being open
- flatten getGlobalSearchResults onto a spec-stable sort
- drop the dead store-side SEARCH_SECTIONS/docs path, the no-op
  CommandSearch surface variant, a duplicate hex regex, a dead
  font-base class, and the TaskItem/FolderedItem shape overlap

* fix(search): paint the palette fog with the dialog's own surface

The frost under the floating input reused the canvas card's --surface-2
gradient, which reads as a tinted band on the palette's --surface-4/
--surface-5 dialog (visible in dark mode). The CommandSearch surface
variant returns — this time with genuinely different values — and the
chrome test pins the host-matching fog.

* fix(search): the palette fog matches the inner --bg panel, not the dialog ring

* fix(search): review-round parity and consistency fixes

- table import command respects the in-progress upload gate
- Export CSV is offered to viewers (matching the header control)
- palette mode flips with the deferred query the ranking ran against
- a gated palette deploy reports the button tooltip's reason via toast

* fix(deploy): use the emcn toast input shape

* fix(search): rename logs view toggles to "Switch to Logs/Dashboard"

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Justin Blumencranz
2026-08-11 00:28:53 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent 2f43148d60
commit 9277e7d2d2
28 changed files with 3219 additions and 866 deletions
@@ -103,6 +103,7 @@ import {
isUntitledName,
uniqueMarkdownName,
} from '@/app/workspace/[workspaceId]/files/untitled-title'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { usePinItem, usePinnedIds, useUnpinItem } from '@/hooks/queries/pinned-items'
@@ -1681,6 +1682,16 @@ export function Files() {
fileInputRef.current?.click()
}, [canEdit, uploading])
useRegisterGlobalCommands(() => [
{ id: 'files-upload', handler: () => handleUploadClick() },
{ id: 'files-new-file', handler: () => void handleCreateFile() },
{ id: 'files-new-folder', handler: () => void handleCreateFolder() },
{ id: 'file-download', handler: () => handleDownloadSelected() },
{ id: 'file-rename', handler: () => handleStartHeaderRename() },
{ id: 'file-share', handler: () => handleShareSelected() },
{ id: 'file-delete', handler: () => handleDeleteSelected() },
])
const searchConfig: SearchConfig = {
value: urlSearchTerm,
onChange: setSearchTerm,
@@ -77,6 +77,7 @@ import {
pageUrlKeys,
} from '@/app/workspace/[workspaceId]/knowledge/[id]/search-params'
import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { CONNECTOR_META_REGISTRY } from '@/connectors/registry'
@@ -707,6 +708,14 @@ export function KnowledgeBase({
setShowAddDocumentsModal(true)
}
useRegisterGlobalCommands(() => [
{ id: 'knowledge-base-new-documents', handler: () => setShowAddDocumentsModal(true) },
{ id: 'knowledge-base-new-connector', handler: () => setShowAddConnectorModal(true) },
{ id: 'knowledge-base-rename', handler: () => kbRename.startRename(id, knowledgeBaseName) },
{ id: 'knowledge-base-tags', handler: () => setShowTagsModal(true) },
{ id: 'knowledge-base-delete', handler: () => setShowDeleteDialog(true) },
])
/**
* Handles bulk enabling of selected documents
*/
@@ -59,6 +59,7 @@ import {
knowledgeUrlKeys,
} from '@/app/workspace/[workspaceId]/knowledge/search-params'
import { filterKnowledgeBases } from '@/app/workspace/[workspaceId]/knowledge/utils/filter'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { CONNECTOR_META_REGISTRY } from '@/connectors/registry'
@@ -726,6 +727,11 @@ export function Knowledge() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workspaceId])
useRegisterGlobalCommands(() => [
{ id: 'knowledge-new-base', handler: () => handleOpenCreateModal() },
{ id: 'knowledge-new-folder', handler: () => void handleCreateFolder() },
])
const handleRenameFolder = useCallback(() => {
const folder = activeFolderRef.current
if (!folder) return
@@ -69,6 +69,7 @@ import {
logSortParams,
} from '@/app/workspace/[workspaceId]/logs/search-params'
import type { Suggestion } from '@/app/workspace/[workspaceId]/logs/types'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { getBlock } from '@/blocks/registry'
import { useFolderMap, useFolders } from '@/hooks/queries/folders'
@@ -698,6 +699,13 @@ export default function Logs() {
debouncedSearchQuery,
])
useRegisterGlobalCommands(() => [
{ id: 'logs-refresh', handler: () => handleRefresh() },
{ id: 'logs-export', handler: () => void handleExport() },
{ id: 'logs-show-dashboard', handler: () => setViewMode('dashboard') },
{ id: 'logs-show-logs', handler: () => setViewMode('logs') },
])
const loadMoreLogs = useCallback(() => {
const { isFetching, hasNextPage, fetchNextPage } = logsQueryRef.current
if (!isFetching && hasNextPage) {
@@ -27,14 +27,15 @@ export interface ParsedShortcut {
export interface GlobalCommand {
id?: string
shortcut: string
/** Keyboard binding. Omit for palette-only commands invoked by id. */
shortcut?: string
allowInEditable?: boolean
handler: (event: KeyboardEvent) => void
}
interface RegistryCommand extends GlobalCommand {
id: string
parsed: ParsedShortcut
parsed: ParsedShortcut | null
}
interface GlobalCommandsContextValue {
@@ -130,7 +131,7 @@ export function GlobalCommandsProvider({ children }: { children: ReactNode }) {
const createdIds: string[] = []
for (const cmd of commands) {
const id = cmd.id ?? generateId()
const parsed = parseShortcut(cmd.shortcut)
const parsed = cmd.shortcut ? parseShortcut(cmd.shortcut) : null
registryRef.current.set(id, {
...cmd,
id,
@@ -152,6 +153,7 @@ export function GlobalCommandsProvider({ children }: { children: ReactNode }) {
if (e.isComposing) return
for (const [, cmd] of registryRef.current) {
if (!cmd.parsed) continue
if (!cmd.allowInEditable && isEditableElement(document.activeElement)) continue
if (matchesShortcut(e, cmd.parsed)) {
@@ -36,6 +36,7 @@ import {
} from '@/app/workspace/[workspaceId]/components/folders'
import { PresenceAvatars } from '@/app/workspace/[workspaceId]/components/presence/presence-avatars'
import { LogDetails } from '@/app/workspace/[workspaceId]/logs/components'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { ImportCsvDialog } from '@/app/workspace/[workspaceId]/tables/components/import-csv-dialog'
import { ImportProgressMenu } from '@/app/workspace/[workspaceId]/tables/components/import-progress-menu'
@@ -1068,6 +1069,34 @@ export function Table({
}
}, [tableData, workspaceId])
useRegisterGlobalCommands(() => [
{
id: 'table-new-column',
handler: () => {
if (!userPermissions.canEdit) return
if (tableDataRef.current?.locks.schemaLocked) {
showBlockedToast('add-column')
return
}
handleAddColumnOfType('string')
},
},
{
id: 'table-export-csv',
handler: () => {
if (!tableDataRef.current?.rowCount) return
void handleExportCsv()
},
},
{
id: 'table-import-csv',
handler: () => {
if (!userPermissions.canEdit || tableDataRef.current?.locks.insertLocked) return
onRequestImportCsv()
},
},
])
const columnOptions = useMemo<ColumnOption[]>(
() =>
columns.map((col) => ({
@@ -46,6 +46,7 @@ import {
useFolderNavigation,
useFolderRowDragDrop,
} from '@/app/workspace/[workspaceId]/components/folders'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import {
ImportCsvDialog,
@@ -1033,6 +1034,17 @@ export function Tables() {
}
}, [workspaceId, folders, currentFolderId, createFolderAsync, setSearchTerm, startFolderRename])
useRegisterGlobalCommands(() => [
{ id: 'tables-new-table', handler: () => void handleCreateTable() },
{ id: 'tables-new-folder', handler: () => void handleCreateFolder() },
{
id: 'tables-import-csv',
handler: () => {
if (!uploading) csvInputRef.current?.click()
},
},
])
const headerActions: ResourceAction[] = useMemo(
() => [
{
@@ -2,13 +2,17 @@
import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
import { Button, cn } from '@sim/emcn'
import { Search, X } from '@sim/emcn/icons'
import { X } from '@sim/emcn/icons'
import { WorkflowBlockBorder, type WorkflowBorderPort } from '@sim/workflow-renderer'
import { Command } from 'cmdk'
import { useParams } from 'next/navigation'
import { usePostHog } from 'posthog-js/react'
import { Handle, type NodeProps, Position } from 'reactflow'
import { captureEvent } from '@/lib/posthog/client'
import {
CommandFadedList,
CommandSearch,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome'
import { MemoizedCommandItem } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items'
import {
BlocksGroup,
@@ -403,10 +407,11 @@ export function ConnectionBlockSelector({ id, data }: NodeProps<ConnectionBlockS
className='relative z-20 flex h-full flex-col overflow-hidden rounded-2xl [clip-path:inset(0_round_16px)]'
>
<div className='relative min-h-0 flex-1'>
<Command.List
<CommandFadedList
ref={listRef}
fade='canvas'
className={cn(
"nodrag nopan nowheel allow-scroll scrollbar-none [&_[cmdk-item][aria-selected='true']]:!border-transparent [&_[cmdk-item][aria-selected='true']]:!bg-[var(--surface-hover)] [&_[cmdk-item]_svg]:!scale-100 [&_[cmdk-item]_svg]:!transition-none h-full overflow-y-auto overflow-x-hidden px-1.5 pt-12 pb-1.5 [-webkit-mask-image:linear-gradient(to_bottom,transparent_0%,transparent_8%,black_18%,black_94%,transparent_100%)] [clip-path:inset(3px_round_13px)] [mask-image:linear-gradient(to_bottom,transparent_0%,transparent_8%,black_18%,black_94%,transparent_100%)] [&_[cmdk-group-items]]:flex [&_[cmdk-group-items]]:flex-col",
"nodrag nopan nowheel allow-scroll scrollbar-none [&_[cmdk-item][aria-selected='true']]:!border-transparent [&_[cmdk-item][aria-selected='true']]:!bg-[var(--surface-hover)] [&_[cmdk-item]_svg]:!scale-100 [&_[cmdk-item]_svg]:!transition-none h-full [clip-path:inset(3px_round_13px)]",
CMDK_ITEM_GAP_CLASS,
CMDK_SECTION_GAP_CLASS
)}
@@ -482,19 +487,16 @@ export function ConnectionBlockSelector({ id, data }: NodeProps<ConnectionBlockS
<ToolsGroup items={browseTools} onSelect={handleToolSelect} />
</>
)}
</Command.List>
<div className='nodrag nopan absolute inset-x-[3px] top-[3px] z-20 flex h-12 cursor-text items-center gap-2 rounded-t-[13px] bg-[linear-gradient(to_bottom,var(--surface-2)_0%,color-mix(in_srgb,var(--surface-2)_88%,transparent)_68%,transparent_100%)] px-2.5 pb-2'>
<Search className='size-[14px] flex-shrink-0 text-[var(--text-muted)]' />
<Command.Input
ref={inputRef}
autoFocus
aria-label='Search blocks'
value={search}
onValueChange={handleSearchChange}
placeholder='Search blocks...'
className='h-8 min-w-0 flex-1 cursor-text bg-transparent text-[var(--text-body)] text-sm outline-none placeholder:text-[var(--text-muted)] focus:outline-none'
/>
</div>
</CommandFadedList>
<CommandSearch
ref={inputRef}
surface='canvas'
autoFocus
aria-label='Search blocks'
value={search}
onValueChange={handleSearchChange}
placeholder='Search blocks...'
/>
</div>
</Command>
</div>
@@ -1,7 +1,8 @@
'use client'
import { useState } from 'react'
import { Chip, Tooltip } from '@sim/emcn'
import { Chip, Tooltip, toast } from '@sim/emcn'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { DeployModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal'
import {
useChangeDetection,
@@ -62,7 +63,7 @@ export function Deploy({ activeWorkflowId, userPermissions, disabled = false }:
(!isDeployed && deployReadiness.isBlocked && !deployReadiness.isSyncing)
const onDeployClick = async () => {
if (disabled || !canDeploy || !activeWorkflowId) return
if (isRegistryLoading || isDisabled || !activeWorkflowId) return
if (isDeploymentSettling) {
setIsModalOpen(true)
@@ -75,6 +76,21 @@ export function Deploy({ activeWorkflowId, userPermissions, disabled = false }:
}
}
useRegisterGlobalCommands(() => [
{
id: 'deploy-workflow',
handler: () => {
/* The palette can't render a disabled state for this action yet, so a
gated invocation reports the same reason the button's tooltip shows. */
if (isRegistryLoading || isDisabled) {
toast({ message: isRegistryLoading ? 'Workflow is still loading' : getTooltipText() })
return
}
void onDeployClick()
},
},
])
const getTooltipText = () => {
if (isEmpty) {
return 'Cannot deploy an empty workflow'
@@ -0,0 +1,118 @@
/**
* @vitest-environment jsdom
*/
import { act } from 'react'
import { Command } from 'cmdk'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
CommandFadedList,
CommandSearch,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome'
describe('CommandFadedList', () => {
let container: HTMLDivElement
let root: Root
let originalScrollIntoView: typeof HTMLElement.prototype.scrollIntoView
beforeEach(() => {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
unobserve() {}
disconnect() {}
}
)
originalScrollIntoView = HTMLElement.prototype.scrollIntoView
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
configurable: true,
value: vi.fn(),
})
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(() => {
act(() => root.unmount())
container.remove()
if (originalScrollIntoView) {
HTMLElement.prototype.scrollIntoView = originalScrollIntoView
} else {
Reflect.deleteProperty(HTMLElement.prototype, 'scrollIntoView')
}
vi.unstubAllGlobals()
})
it('fades the palette with the short single mask and the shared search surface', () => {
act(() => {
root.render(
<Command>
<CommandFadedList fade='palette' />
<CommandSearch surface='palette' aria-label='Search' />
</Command>
)
})
const list = container.querySelector('[cmdk-list]')
const search = container.querySelector('[cmdk-input]')?.parentElement
expect(list?.className).toContain('transparent_8%,black_13%,black_97%')
expect(list?.className).not.toContain('scrollbar-track')
expect(search?.className).toContain('var(--bg)')
})
it('cycles through palette results with Tab and Shift+Tab', () => {
act(() => {
root.render(
<Command loop>
<CommandSearch surface='palette' aria-label='Search' cycleResultsOnTab />
<CommandFadedList fade='palette'>
<Command.Item value='first'>First</Command.Item>
<Command.Item value='second'>Second</Command.Item>
</CommandFadedList>
</Command>
)
})
const input = container.querySelector<HTMLInputElement>('[cmdk-input]')
const selectedResult = () =>
container.querySelector('[cmdk-item][aria-selected="true"]')?.textContent
expect(input).not.toBeNull()
expect(selectedResult()).toBe('First')
const firstTabEvent = new KeyboardEvent('keydown', {
key: 'Tab',
bubbles: true,
cancelable: true,
})
act(() => {
input?.focus()
input?.dispatchEvent(firstTabEvent)
})
expect(selectedResult()).toBe('Second')
expect(firstTabEvent.defaultPrevented).toBe(true)
expect(document.activeElement).toBe(input)
act(() => {
input?.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true })
)
})
expect(selectedResult()).toBe('First')
act(() => {
input?.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Tab',
shiftKey: true,
bubbles: true,
cancelable: true,
})
)
})
expect(selectedResult()).toBe('Second')
})
})
@@ -0,0 +1,112 @@
'use client'
import {
type ComponentPropsWithoutRef,
forwardRef,
type KeyboardEvent,
type ReactNode,
} from 'react'
import { cn } from '@sim/emcn'
import { Search } from '@sim/emcn/icons'
import { Command } from 'cmdk'
type CommandInputProps = ComponentPropsWithoutRef<typeof Command.Input>
type CommandListProps = ComponentPropsWithoutRef<typeof Command.List>
interface CommandSearchProps extends Omit<CommandInputProps, 'className'> {
surface: 'canvas' | 'palette'
cycleResultsOnTab?: boolean
/** Trailing slot after the input (e.g. a mode hint). Non-interactive. */
endAdornment?: ReactNode
}
interface CommandFadedListProps extends CommandListProps {
fade: 'canvas' | 'palette'
}
/**
* The fog must repaint its host's exact background or it reads as a tinted
* band under the input: the canvas selector card fills with `--surface-2`,
* while the palette's rows sit on the inner `--bg` panel (the dialog's
* surface-4/5 is only the 3px ring around it).
*/
const SEARCH_SURFACE_CLASSNAME = {
canvas:
'bg-[linear-gradient(to_bottom,var(--surface-2)_0%,color-mix(in_srgb,var(--surface-2)_88%,transparent)_68%,transparent_100%)]',
palette:
'bg-[linear-gradient(to_bottom,var(--bg)_0%,color-mix(in_srgb,var(--bg)_88%,transparent)_68%,transparent_100%)]',
} as const
/**
* The palette hides its scrollbar (`scrollbar-none` at the call site), so it
* fades with one plain mask; its band is kept short — fully masked only under
* the floating input (08%), legible by 13%, and a brief 97100% exit — so
* rows spend less time in the fog than on the canvas surface.
*/
const LIST_FADE_CLASSNAME = {
canvas:
'[-webkit-mask-image:linear-gradient(to_bottom,transparent_0%,transparent_8%,black_18%,black_94%,transparent_100%)] [mask-image:linear-gradient(to_bottom,transparent_0%,transparent_8%,black_18%,black_94%,transparent_100%)]',
palette:
'[-webkit-mask-image:linear-gradient(to_bottom,transparent_0%,transparent_8%,black_13%,black_97%,transparent_100%)] [mask-image:linear-gradient(to_bottom,transparent_0%,transparent_8%,black_13%,black_97%,transparent_100%)]',
} as const
/** Borderless search field layered over a fading command-result list. */
export const CommandSearch = forwardRef<HTMLInputElement, CommandSearchProps>(
function CommandSearch(
{ surface, cycleResultsOnTab = false, endAdornment, onKeyDown, ...props },
ref
) {
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
onKeyDown?.(event)
if (!cycleResultsOnTab || event.defaultPrevented || event.key !== 'Tab') return
event.preventDefault()
event.currentTarget.dispatchEvent(
new window.KeyboardEvent('keydown', {
key: event.shiftKey ? 'ArrowUp' : 'ArrowDown',
bubbles: true,
cancelable: true,
})
)
}
return (
<div
className={cn(
'nodrag nopan absolute inset-x-[3px] top-[3px] z-20 flex h-12 cursor-text items-center gap-2 rounded-t-[13px] px-2.5 pb-2',
SEARCH_SURFACE_CLASSNAME[surface]
)}
>
<Search className='size-[14px] flex-shrink-0 text-[var(--text-muted)]' />
<Command.Input
ref={ref}
className='h-8 min-w-0 flex-1 cursor-text bg-transparent text-[var(--text-body)] text-sm outline-none placeholder:text-[var(--text-muted)] focus:outline-none'
onKeyDown={handleKeyDown}
{...props}
/>
{endAdornment}
</div>
)
}
)
CommandSearch.displayName = 'CommandSearch'
/** Scrollable command list with soft edge fades tuned for each command surface. */
export const CommandFadedList = forwardRef<HTMLDivElement, CommandFadedListProps>(
function CommandFadedList({ className, fade, ...props }, ref) {
return (
<Command.List
ref={ref}
className={cn(
'overflow-y-auto overflow-x-hidden px-1.5 pt-12 pb-1.5 [&_[cmdk-group-items]]:flex [&_[cmdk-group-items]]:flex-col',
LIST_FADE_CLASSNAME[fade],
className
)}
{...props}
/>
)
}
)
CommandFadedList.displayName = 'CommandFadedList'
@@ -0,0 +1 @@
export { CommandFadedList, CommandSearch } from './command-chrome'
@@ -0,0 +1,79 @@
/**
* @vitest-environment jsdom
*/
import { act } from 'react'
import { Command } from 'cmdk'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { MemoizedActionItem } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items'
interface TestIconProps {
className?: string
}
function TestIcon({ className }: TestIconProps) {
return <svg className={className} />
}
describe('MemoizedActionItem', () => {
let container: HTMLDivElement
let root: Root
let originalScrollIntoView: typeof HTMLElement.prototype.scrollIntoView
beforeEach(() => {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
unobserve() {}
disconnect() {}
}
)
originalScrollIntoView = HTMLElement.prototype.scrollIntoView
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
configurable: true,
value: vi.fn(),
})
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(() => {
act(() => root.unmount())
container.remove()
vi.unstubAllGlobals()
if (originalScrollIntoView) {
HTMLElement.prototype.scrollIntoView = originalScrollIntoView
} else {
Reflect.deleteProperty(HTMLElement.prototype, 'scrollIntoView')
}
})
it('centers the command glyph in a fixed three-slot shortcut hint', () => {
act(() => {
root.render(
<Command>
<Command.List>
<MemoizedActionItem
value='run-workflow'
onSelect={vi.fn()}
icon={TestIcon}
name='Run workflow'
shortcut='⌘↵'
/>
</Command.List>
</Command>
)
})
const shortcut = container.querySelector('[aria-label="Keyboard shortcut ⌘↵"]')
expect(Array.from(shortcut?.children ?? []).map((slot) => slot.textContent)).toEqual([
'',
'⌘',
'↵',
])
expect(container.querySelector('button[aria-label*="favorites"]')).toBeNull()
})
})
@@ -6,10 +6,79 @@ import { cn } from '@sim/emcn'
import { File, Workflow } from '@sim/emcn/icons'
import { WorkflowTypeIcon } from '@sim/workflow-renderer'
import { Command } from 'cmdk'
import { HEX_COLOR_REGEX } from '@/lib/branding'
import type { CommandItemProps } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils'
import { COMMAND_ITEM_CLASSNAME } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils'
import { getTileIconColorClass } from '@/blocks/icon-color'
interface ResultMetaProps {
meta?: string
}
interface ItemMetaProps {
meta: string
}
function ItemMeta({ meta }: ItemMetaProps) {
return (
<span className='ml-auto flex-shrink-0 pl-2 text-[var(--text-subtle)] text-small'>{meta}</span>
)
}
interface ItemFolderPathProps {
folderPath: string[]
}
/** Trailing folder-path receipt whose head segments yield space to the leaf. */
function ItemFolderPath({ folderPath }: ItemFolderPathProps) {
return (
<span className='ml-auto flex min-w-0 pl-2 text-[var(--text-subtle)] text-small'>
{folderPath.length > 1 && (
<>
<span className='min-w-0 truncate [flex-shrink:9999]'>
{folderPath.slice(0, -1).join(' / ')}
</span>
<span className='flex-shrink-0 whitespace-pre'> / </span>
</>
)}
<span className='min-w-0 truncate'>{folderPath[folderPath.length - 1]}</span>
</span>
)
}
/** Structural equality for the optional folder-path prop in memo comparators. */
function sameFolderPath(prev?: string[], next?: string[]): boolean {
return (
prev === next ||
(prev?.length === next?.length && (prev ?? []).every((segment, i) => segment === next?.[i]))
)
}
interface ShortcutHintProps {
shortcut: string
}
function ShortcutHint({ shortcut }: ShortcutHintProps) {
const commandIndex = shortcut.indexOf('⌘')
const slots =
commandIndex === -1
? ['', '', shortcut]
: [shortcut.slice(0, commandIndex), '⌘', shortcut.slice(commandIndex + 1)]
return (
<span
aria-label={`Keyboard shortcut ${shortcut}`}
className='ml-auto grid w-10 flex-shrink-0 grid-cols-3 text-center text-[var(--text-subtle)] text-small'
>
{slots.map((slot, index) => (
<span key={`${index}-${slot}`} aria-hidden='true'>
{slot}
</span>
))}
</span>
)
}
export const MemoizedCommandItem = memo(
function CommandItem({
value,
@@ -19,6 +88,8 @@ export const MemoizedCommandItem = memo(
showColoredIcon,
workflowType,
label,
labelPrefix,
meta,
}: CommandItemProps) {
return (
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
@@ -39,7 +110,11 @@ export const MemoizedCommandItem = memo(
/>
</div>
)}
<span className='truncate text-[var(--text-body)]'>{label}</span>
<span className='truncate text-[var(--text-body)]'>
{labelPrefix && <span className='text-[var(--text-subtle)]'>{labelPrefix} </span>}
{label}
</span>
{meta ? <ItemMeta meta={meta} /> : null}
</Command.Item>
)
},
@@ -49,7 +124,9 @@ export const MemoizedCommandItem = memo(
prev.bgColor === next.bgColor &&
prev.showColoredIcon === next.showColoredIcon &&
prev.workflowType === next.workflowType &&
prev.label === next.label
prev.label === next.label &&
prev.labelPrefix === next.labelPrefix &&
prev.meta === next.meta
)
export const MemoizedActionItem = memo(
@@ -59,22 +136,19 @@ export const MemoizedActionItem = memo(
icon: Icon,
name,
shortcut,
meta,
}: {
value: string
onSelect: () => void
icon: ComponentType<{ className?: string }>
name: string
shortcut?: string
}) {
} & ResultMetaProps) {
return (
<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>
{shortcut && (
<span className='ml-auto flex-shrink-0 text-[var(--text-subtle)] text-small'>
{shortcut}
</span>
)}
{meta ? <ItemMeta meta={meta} /> : shortcut ? <ShortcutHint shortcut={shortcut} /> : null}
</Command.Item>
)
},
@@ -82,38 +156,10 @@ export const MemoizedActionItem = memo(
prev.value === next.value &&
prev.icon === next.icon &&
prev.name === next.name &&
prev.shortcut === next.shortcut
prev.shortcut === next.shortcut &&
prev.meta === next.meta
)
/**
* Right-aligned folder breadcrumb. All but the last segment collapse first so a
* deep path degrades to the immediate parent rather than truncating the whole
* trail. Renders nothing at the workspace root.
*/
function FolderPathSuffix({ folderPath }: { folderPath?: string[] }) {
if (!folderPath || folderPath.length === 0) return null
return (
<span className='ml-auto flex min-w-0 pl-2 text-[var(--text-subtle)] text-small'>
{folderPath.length > 1 && (
<>
<span className='min-w-0 truncate [flex-shrink:9999]'>
{folderPath.slice(0, -1).join(' / ')}
</span>
<span className='flex-shrink-0 whitespace-pre'> / </span>
</>
)}
<span className='min-w-0 truncate'>{folderPath[folderPath.length - 1]}</span>
</span>
)
}
/** Element-wise compare so a rebuilt-but-identical path array skips the re-render. */
function sameFolderPath(a?: string[], b?: string[]): boolean {
if (a === b) return true
if (a?.length !== b?.length) return false
return (a ?? []).every((segment, i) => segment === b?.[i])
}
export const MemoizedWorkflowItem = memo(
function WorkflowItem({
value,
@@ -121,13 +167,14 @@ export const MemoizedWorkflowItem = memo(
name,
folderPath,
isCurrent,
meta,
}: {
value: string
onSelect: () => void
name: string
folderPath?: string[]
isCurrent?: boolean
}) {
} & ResultMetaProps) {
return (
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
<div className='relative flex size-[16px] flex-shrink-0 items-center justify-center'>
@@ -137,7 +184,11 @@ export const MemoizedWorkflowItem = memo(
<span className='truncate'>{name}</span>
{isCurrent && <span className='flex-shrink-0 whitespace-pre'> (current)</span>}
</span>
<FolderPathSuffix folderPath={folderPath} />
{meta ? (
<ItemMeta meta={meta} />
) : folderPath && folderPath.length > 0 ? (
<ItemFolderPath folderPath={folderPath} />
) : null}
</Command.Item>
)
},
@@ -145,6 +196,7 @@ export const MemoizedWorkflowItem = memo(
prev.value === next.value &&
prev.name === next.name &&
prev.isCurrent === next.isCurrent &&
prev.meta === next.meta &&
sameFolderPath(prev.folderPath, next.folderPath)
)
@@ -154,12 +206,13 @@ export const MemoizedFileItem = memo(
onSelect,
name,
folderPath,
meta,
}: {
value: string
onSelect: () => void
name: string
folderPath?: string[]
}) {
} & ResultMetaProps) {
return (
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
<div className='relative flex size-[16px] flex-shrink-0 items-center justify-center'>
@@ -168,13 +221,18 @@ export const MemoizedFileItem = memo(
<span className='flex min-w-0 max-w-[75%] flex-shrink-0 text-[var(--text-body)]'>
<span className='truncate'>{name}</span>
</span>
<FolderPathSuffix folderPath={folderPath} />
{meta ? (
<ItemMeta meta={meta} />
) : folderPath && folderPath.length > 0 ? (
<ItemFolderPath folderPath={folderPath} />
) : null}
</Command.Item>
)
},
(prev, next) =>
prev.value === next.value &&
prev.name === next.name &&
prev.meta === next.meta &&
sameFolderPath(prev.folderPath, next.folderPath)
)
@@ -183,18 +241,20 @@ export const MemoizedTaskItem = memo(
value,
onSelect,
name,
meta,
}: {
value: string
onSelect: () => void
name: string
}) {
} & ResultMetaProps) {
return (
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
<span className='truncate text-[var(--text-body)]'>{name}</span>
{meta && <ItemMeta meta={meta} />}
</Command.Item>
)
},
(prev, next) => prev.value === next.value && prev.name === next.name
(prev, next) => prev.value === next.value && prev.name === next.name && prev.meta === next.meta
)
export const MemoizedWorkspaceItem = memo(
@@ -203,23 +263,55 @@ export const MemoizedWorkspaceItem = memo(
onSelect,
name,
isCurrent,
logoUrl,
color,
meta,
}: {
value: string
onSelect: () => void
name: string
isCurrent?: boolean
}) {
logoUrl?: string | null
color?: string
} & ResultMetaProps) {
const backgroundColor = color && HEX_COLOR_REGEX.test(color) ? color : 'var(--brand-accent)'
return (
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
{logoUrl ? (
<img
data-slot='workspace-icon'
src={logoUrl}
alt=''
className='size-[16px] flex-shrink-0 rounded-sm object-cover'
/>
) : (
<span
data-slot='workspace-icon'
aria-hidden='true'
className='relative flex size-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-sm font-medium text-[9px] text-white leading-none'
>
<svg className='absolute inset-0 size-full' viewBox='0 0 16 16'>
<rect width='16' height='16' rx='2' fill={backgroundColor} />
</svg>
<span className='relative'>{name.charAt(0).toUpperCase() || 'W'}</span>
</span>
)}
<span className='flex min-w-0 text-[var(--text-body)]'>
<span className='truncate'>{name}</span>
{isCurrent && <span className='flex-shrink-0 whitespace-pre'> (current)</span>}
</span>
{meta && <ItemMeta meta={meta} />}
</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.logoUrl === next.logoUrl &&
prev.color === next.color &&
prev.meta === next.meta
)
export const MemoizedPageItem = memo(
@@ -229,22 +321,19 @@ export const MemoizedPageItem = memo(
icon: Icon,
name,
shortcut,
meta,
}: {
value: string
onSelect: () => void
icon: ComponentType<{ className?: string }>
name: string
shortcut?: string
}) {
} & ResultMetaProps) {
return (
<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>
{shortcut && (
<span className='ml-auto flex-shrink-0 text-[var(--text-subtle)] text-small'>
{shortcut}
</span>
)}
{meta ? <ItemMeta meta={meta} /> : shortcut ? <ShortcutHint shortcut={shortcut} /> : null}
</Command.Item>
)
},
@@ -252,7 +341,8 @@ export const MemoizedPageItem = memo(
prev.value === next.value &&
prev.icon === next.icon &&
prev.name === next.name &&
prev.shortcut === next.shortcut
prev.shortcut === next.shortcut &&
prev.meta === next.meta
)
export const MemoizedIconItem = memo(
@@ -262,20 +352,25 @@ export const MemoizedIconItem = memo(
name,
icon: Icon,
folderPath,
meta,
}: {
value: string
onSelect: () => void
name: string
icon: ComponentType<{ className?: string }>
folderPath?: string[]
}) {
} & ResultMetaProps) {
return (
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
<Icon className='size-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='flex min-w-0 max-w-[75%] flex-shrink-0 text-[var(--text-body)]'>
<span className='truncate'>{name}</span>
</span>
<FolderPathSuffix folderPath={folderPath} />
{meta ? (
<ItemMeta meta={meta} />
) : folderPath && folderPath.length > 0 ? (
<ItemFolderPath folderPath={folderPath} />
) : null}
</Command.Item>
)
},
@@ -283,5 +378,6 @@ export const MemoizedIconItem = memo(
prev.value === next.value &&
prev.name === next.name &&
prev.icon === next.icon &&
prev.meta === next.meta &&
sameFolderPath(prev.folderPath, next.folderPath)
)
@@ -1,3 +1,4 @@
export { CommandFadedList, CommandSearch } from './command-chrome'
export {
MemoizedCommandItem,
MemoizedFileItem,
@@ -7,19 +8,4 @@ export {
MemoizedWorkflowItem,
MemoizedWorkspaceItem,
} from './command-items'
export {
BlocksGroup,
ChatsGroup,
ConnectedAccountsGroup,
DocsGroup,
FilesGroup,
IntegrationsGroup,
KnowledgeBasesGroup,
PagesGroup,
TablesGroup,
ToolOpsGroup,
ToolsGroup,
TriggersGroup,
WorkflowsGroup,
WorkspacesGroup,
} from './search-groups'
export { BlocksGroup, SearchEntryGroup, ToolsGroup } from './search-groups'
@@ -1,17 +1 @@
export {
ActionsGroup,
BlocksGroup,
ChatsGroup,
ConnectedAccountsGroup,
DocsGroup,
FilesGroup,
IntegrationsGroup,
KnowledgeBasesGroup,
PagesGroup,
TablesGroup,
ToolOpsGroup,
ToolsGroup,
TriggersGroup,
WorkflowsGroup,
WorkspacesGroup,
} from './search-groups'
export { BlocksGroup, SearchEntryGroup, ToolsGroup } from './search-groups'
@@ -0,0 +1,156 @@
/**
* @vitest-environment jsdom
*/
import { act } from 'react'
import { Command } from 'cmdk'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { SearchEntryGroup } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups'
import type {
SearchEntry,
SearchEntryHandlers,
WorkspaceItem,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils'
function TestIcon() {
return <svg />
}
const handlers: SearchEntryHandlers = {
onSelectAction: vi.fn(),
onSelectConnectedAccount: vi.fn(),
onSelectIntegration: vi.fn(),
onSelectChat: vi.fn(),
onSelectWorkflow: vi.fn(),
onSelectTable: vi.fn(),
onSelectFile: vi.fn(),
onSelectKnowledgeBase: vi.fn(),
onSelectLog: vi.fn(),
onSelectWorkspace: vi.fn(),
onSelectPage: vi.fn(),
}
const actionEntry: SearchEntry = {
section: 'actions',
score: 100,
item: {
id: 'run-workflow',
name: 'Run workflow',
icon: TestIcon,
context: 'workflow',
run: vi.fn(),
},
}
const workspaceItems: WorkspaceItem[] = [
{
id: 'workspace-acme',
name: 'Acme',
href: '/workspace/workspace-acme/w',
logoUrl: 'https://cdn.example.com/acme.png',
},
{
id: 'workspace-beta',
name: 'Beta Workspace',
href: '/workspace/workspace-beta/w',
color: '#123456',
},
]
const workspaceEntries: SearchEntry[] = workspaceItems.map((item, index) => ({
section: 'workspaces',
score: 100 - index,
item,
}))
describe('SearchEntryGroup', () => {
let container: HTMLDivElement
let root: Root
let originalScrollIntoView: typeof HTMLElement.prototype.scrollIntoView
beforeEach(() => {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
unobserve() {}
disconnect() {}
}
)
originalScrollIntoView = HTMLElement.prototype.scrollIntoView
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
configurable: true,
value: vi.fn(),
})
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(() => {
act(() => root.unmount())
container.remove()
vi.unstubAllGlobals()
if (originalScrollIntoView) {
HTMLElement.prototype.scrollIntoView = originalScrollIntoView
} else {
Reflect.deleteProperty(HTMLElement.prototype, 'scrollIntoView')
}
})
it('renders flat search results without passing a null group heading to cmdk', () => {
act(() => {
root.render(
<Command>
<Command.List>
<SearchEntryGroup variant='results' entries={[actionEntry]} handlers={handlers} />
</Command.List>
</Command>
)
})
expect(container.textContent).toContain('Run workflow')
expect(container.querySelector('[cmdk-group-heading]')).toBeNull()
expect(container.querySelector('button[aria-label*="favorites"]')).toBeNull()
})
it('renders workspace logos and initial fallbacks in workspace results', () => {
act(() => {
root.render(
<Command>
<Command.List>
<SearchEntryGroup variant='results' entries={workspaceEntries} handlers={handlers} />
</Command.List>
</Command>
)
})
const logo = container.querySelector<HTMLImageElement>('img[data-slot="workspace-icon"]')
const fallback = container.querySelector('span[data-slot="workspace-icon"]')
expect(logo?.src).toBe('https://cdn.example.com/acme.png')
expect(logo?.alt).toBe('')
expect(fallback?.textContent).toBe('B')
expect(fallback?.querySelector('rect')?.getAttribute('fill')).toBe('#123456')
})
it('renders workspace icons in the default workspace section', () => {
act(() => {
root.render(
<Command>
<Command.List>
<SearchEntryGroup
variant='section'
heading='Workspaces'
entries={workspaceEntries}
handlers={handlers}
/>
</Command.List>
</Command>
)
})
expect(container.querySelector('img[data-slot="workspace-icon"]')).not.toBeNull()
expect(container.querySelector('span[data-slot="workspace-icon"]')?.textContent).toBe('B')
})
})
@@ -1,7 +1,8 @@
'use client'
import type { ComponentType } from 'react'
import type { ReactElement } from 'react'
import { memo } from 'react'
import { Library } from '@sim/emcn'
import { Database, Table } from '@sim/emcn/icons'
import { Command } from 'cmdk'
import {
@@ -15,46 +16,13 @@ import {
MemoizedWorkspaceItem,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items'
import type {
ActionItem,
FileItem,
FolderedItem,
IntegrationSearchItem,
PageItem,
TaskItem,
WorkflowItem,
WorkspaceItem,
SearchEntry,
SearchEntryHandlers,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils'
import { GROUP_HEADING_CLASSNAME } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils'
import type {
SearchBlockItem,
SearchDocItem,
SearchToolOperationItem,
} from '@/stores/modals/search/types'
export const ActionsGroup = memo(function ActionsGroup({
items,
onSelect,
}: {
items: ActionItem[]
onSelect: (action: ActionItem) => void
}) {
if (items.length === 0) return null
return (
<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}
/>
))}
</Command.Group>
)
})
import type { SearchBlockItem } from '@/stores/modals/search/types'
/** Canvas block group, consumed by the connection block selector. */
export const BlocksGroup = memo(function BlocksGroup({
items,
onSelect,
@@ -83,6 +51,7 @@ export const BlocksGroup = memo(function BlocksGroup({
)
})
/** Canvas tool group, consumed by the connection block selector. */
export const ToolsGroup = memo(function ToolsGroup({
items,
onSelect,
@@ -108,262 +77,219 @@ export const ToolsGroup = memo(function ToolsGroup({
)
})
export const TriggersGroup = memo(function TriggersGroup({
items,
onSelect,
}: {
items: SearchBlockItem[]
onSelect: (trigger: SearchBlockItem) => void
}) {
if (items.length === 0) return null
return (
<Command.Group heading='Triggers' className={GROUP_HEADING_CLASSNAME}>
{items.map((trigger) => (
interface RenderEntryOptions {
keyPrefix: string
}
function renderSearchEntry(
entry: SearchEntry,
handlers: SearchEntryHandlers,
options: RenderEntryOptions
): ReactElement {
const key = `${options.keyPrefix}${entry.section}-${entry.item.id}`
switch (entry.section) {
case 'actions':
return (
<MemoizedActionItem
key={key}
value={`${entry.item.name} ${entry.item.keywords ?? ''} ${key}`}
onSelect={() => handlers.onSelectAction(entry.item)}
icon={entry.item.icon}
name={entry.item.name}
shortcut={entry.item.shortcut}
/>
)
case 'blocks':
return (
<MemoizedCommandItem
key={trigger.id}
value={`${trigger.name} trigger-${trigger.id}`}
onSelect={() => onSelect(trigger)}
icon={trigger.icon}
bgColor={trigger.bgColor}
key={key}
value={`${entry.item.name} ${key}`}
onSelect={() => handlers.onSelectBlock(entry.item)}
icon={entry.item.icon}
bgColor={entry.item.bgColor}
showColoredIcon
label={trigger.name}
workflowType={entry.item.type}
label={entry.item.name}
/>
))}
</Command.Group>
)
})
export const ToolOpsGroup = memo(function ToolOpsGroup({
items,
onSelect,
}: {
items: SearchToolOperationItem[]
onSelect: (op: SearchToolOperationItem) => void
}) {
if (items.length === 0) return null
return (
<Command.Group heading='Tool operations' className={GROUP_HEADING_CLASSNAME}>
{items.map((op) => (
)
case 'tools':
return (
<MemoizedCommandItem
key={op.id}
value={`${op.searchValue} operation-${op.id}`}
onSelect={() => onSelect(op)}
icon={op.icon}
bgColor={op.bgColor}
key={key}
value={`${entry.item.name} ${key}`}
onSelect={() => handlers.onSelectTool(entry.item)}
icon={entry.item.icon}
bgColor={entry.item.bgColor}
showColoredIcon
label={op.name}
label={entry.item.name}
/>
))}
</Command.Group>
)
})
export const DocsGroup = memo(function DocsGroup({
items,
onSelect,
}: {
items: SearchDocItem[]
onSelect: (doc: SearchDocItem) => void
}) {
if (items.length === 0) return null
return (
<Command.Group heading='Docs' className={GROUP_HEADING_CLASSNAME}>
{items.map((doc) => (
)
case 'triggers':
return (
<MemoizedCommandItem
key={doc.id}
value={`${doc.name} docs documentation doc-${doc.id}`}
onSelect={() => onSelect(doc)}
icon={doc.icon}
bgColor='#6B7280'
key={key}
value={`${entry.item.name} ${key}`}
onSelect={() => handlers.onSelectTrigger(entry.item)}
icon={entry.item.icon}
bgColor={entry.item.bgColor}
showColoredIcon
label={doc.name}
label={entry.item.name}
/>
))}
</Command.Group>
)
})
export const WorkflowsGroup = memo(function WorkflowsGroup({
items,
onSelect,
}: {
items: WorkflowItem[]
onSelect: (workflow: WorkflowItem) => void
}) {
if (items.length === 0) return null
return (
<Command.Group heading='Workflows' className={GROUP_HEADING_CLASSNAME}>
{items.map((workflow) => (
<MemoizedWorkflowItem
key={workflow.id}
value={`${workflow.name} ${workflow.folderPath?.join(' / ') ?? ''} workflow-${workflow.id}`}
onSelect={() => onSelect(workflow)}
name={workflow.name}
folderPath={workflow.folderPath}
isCurrent={workflow.isCurrent}
)
case 'toolOperations':
return (
<MemoizedCommandItem
key={key}
value={`${entry.item.searchValue} ${key}`}
onSelect={() => handlers.onSelectToolOperation(entry.item)}
icon={entry.item.icon}
bgColor={entry.item.bgColor}
showColoredIcon
labelPrefix={entry.item.serviceName}
label={entry.item.name}
/>
))}
</Command.Group>
)
})
export const ChatsGroup = memo(function ChatsGroup({
items,
onSelect,
}: {
items: TaskItem[]
onSelect: (task: TaskItem) => void
}) {
if (items.length === 0) return null
return (
<Command.Group heading='Chats' className={GROUP_HEADING_CLASSNAME}>
{items.map((task) => (
)
case 'connectedAccounts':
return (
<MemoizedCommandItem
key={key}
value={`${entry.item.name} ${key}`}
onSelect={() => handlers.onSelectConnectedAccount(entry.item)}
icon={entry.item.icon}
bgColor={entry.item.bgColor}
showColoredIcon
label={entry.item.name}
/>
)
case 'integrations':
return (
<MemoizedCommandItem
key={key}
value={`${entry.item.name} ${key}`}
onSelect={() => handlers.onSelectIntegration(entry.item)}
icon={entry.item.icon}
bgColor={entry.item.bgColor}
showColoredIcon
label={entry.item.name}
/>
)
case 'chats':
return (
<MemoizedTaskItem
key={task.id}
value={`${task.name} task-${task.id}`}
onSelect={() => onSelect(task)}
name={task.name}
key={key}
value={`${entry.item.name} ${key}`}
onSelect={() => handlers.onSelectChat(entry.item)}
name={entry.item.name}
meta={entry.item.date}
/>
))}
</Command.Group>
)
})
export const WorkspacesGroup = memo(function WorkspacesGroup({
items,
onSelect,
}: {
items: WorkspaceItem[]
onSelect: (workspace: WorkspaceItem) => void
}) {
if (items.length === 0) return null
return (
<Command.Group heading='Workspaces' className={GROUP_HEADING_CLASSNAME}>
{items.map((workspace) => (
<MemoizedWorkspaceItem
key={workspace.id}
value={`${workspace.name} workspace-${workspace.id}`}
onSelect={() => onSelect(workspace)}
name={workspace.name}
isCurrent={workspace.isCurrent}
)
case 'workflows':
return (
<MemoizedWorkflowItem
key={key}
value={`${entry.item.name} ${entry.item.folderPath?.join(' / ') ?? ''} ${key}`}
onSelect={() => handlers.onSelectWorkflow(entry.item)}
name={entry.item.name}
folderPath={entry.item.folderPath}
isCurrent={entry.item.isCurrent}
/>
))}
</Command.Group>
)
})
export const PagesGroup = memo(function PagesGroup({
items,
onSelect,
}: {
items: PageItem[]
onSelect: (page: PageItem) => void
}) {
if (items.length === 0) return null
return (
<Command.Group heading='Pages' className={GROUP_HEADING_CLASSNAME}>
{items.map((page) => (
<MemoizedPageItem
key={page.id}
value={`${page.name} page-${page.id}`}
onSelect={() => onSelect(page)}
icon={page.icon}
name={page.name}
shortcut={page.shortcut}
)
case 'tables':
return (
<MemoizedIconItem
key={key}
value={`${entry.item.name} ${entry.item.folderPath?.join(' / ') ?? ''} ${key}`}
onSelect={() => handlers.onSelectTable(entry.item)}
name={entry.item.name}
icon={Table}
folderPath={entry.item.folderPath}
/>
))}
</Command.Group>
)
})
export const TablesGroup = createIconGroup('Tables', 'table', Table)
export const KnowledgeBasesGroup = createIconGroup('Knowledge bases', 'knowledge-base', Database)
export const ConnectedAccountsGroup = createColoredIconGroup('Connected', 'connected-account')
export const IntegrationsGroup = createColoredIconGroup('Integrations', 'integration')
export const FilesGroup = memo(function FilesGroup({
items,
onSelect,
}: {
items: FileItem[]
onSelect: (file: FileItem) => void
}) {
if (items.length === 0) return null
return (
<Command.Group heading='Files' className={GROUP_HEADING_CLASSNAME}>
{items.map((file) => (
)
case 'files':
return (
<MemoizedFileItem
key={file.id}
value={`${file.name} ${file.folderPath?.join(' / ') ?? ''} file-${file.id}`}
onSelect={() => onSelect(file)}
name={file.name}
folderPath={file.folderPath}
key={key}
value={`${entry.item.name} ${entry.item.folderPath?.join(' / ') ?? ''} ${key}`}
onSelect={() => handlers.onSelectFile(entry.item)}
name={entry.item.name}
folderPath={entry.item.folderPath}
/>
))}
)
case 'knowledgeBases':
return (
<MemoizedIconItem
key={key}
value={`${entry.item.name} ${entry.item.folderPath?.join(' / ') ?? ''} ${key}`}
onSelect={() => handlers.onSelectKnowledgeBase(entry.item)}
name={entry.item.name}
icon={Database}
folderPath={entry.item.folderPath}
/>
)
case 'logs':
return (
<MemoizedIconItem
key={key}
value={`${entry.item.name} ${key}`}
onSelect={() => handlers.onSelectLog(entry.item)}
name={entry.item.name}
icon={Library}
meta={entry.item.date}
/>
)
case 'workspaces':
return (
<MemoizedWorkspaceItem
key={key}
value={`${entry.item.name} ${key}`}
onSelect={() => handlers.onSelectWorkspace(entry.item)}
name={entry.item.name}
isCurrent={entry.item.isCurrent}
logoUrl={entry.item.logoUrl}
color={entry.item.color}
/>
)
case 'pages':
return (
<MemoizedPageItem
key={key}
value={`${entry.item.name} ${key}`}
onSelect={() => handlers.onSelectPage(entry.item)}
icon={entry.item.icon}
name={entry.item.name}
shortcut={entry.item.shortcut}
/>
)
}
}
interface SearchEntryGroupProps {
variant: 'section' | 'results'
heading?: string
entries: SearchEntry[]
handlers: SearchEntryHandlers
}
/** Renders ordinary and aggregate rows with their existing section chrome. */
export const SearchEntryGroup = memo(function SearchEntryGroup({
variant,
heading,
entries,
handlers,
}: SearchEntryGroupProps) {
if (entries.length === 0) return null
const keyPrefix = variant === 'results' ? 'results-' : ''
const renderedEntries = entries.map((entry) => renderSearchEntry(entry, handlers, { keyPrefix }))
if (variant === 'results') {
return <Command.Group className={GROUP_HEADING_CLASSNAME}>{renderedEntries}</Command.Group>
}
return (
<Command.Group heading={heading} className={GROUP_HEADING_CLASSNAME}>
{renderedEntries}
</Command.Group>
)
})
/**
* Factory for groups that render each item with its own brand icon on a
* brand-colored tile (the same `showColoredIcon` pattern used by
* `BlocksGroup` / `ToolsGroup`). Used for integrations and connected accounts
* where every row has a distinct per-item icon and brand color.
*/
function createColoredIconGroup(heading: string, prefix: string) {
return memo(function ColoredIconGroup({
items,
onSelect,
}: {
items: IntegrationSearchItem[]
onSelect: (item: IntegrationSearchItem) => void
}) {
if (items.length === 0) return null
return (
<Command.Group heading={heading} className={GROUP_HEADING_CLASSNAME}>
{items.map((item) => (
<MemoizedCommandItem
key={item.id}
value={`${item.name} ${prefix}-${item.id}`}
onSelect={() => onSelect(item)}
icon={item.icon}
bgColor={item.bgColor}
showColoredIcon
label={item.name}
/>
))}
</Command.Group>
)
})
}
function createIconGroup(
heading: string,
prefix: string,
icon: ComponentType<{ className?: string }>
) {
return memo(function IconGroup({
items,
onSelect,
}: {
items: FolderedItem[]
onSelect: (item: FolderedItem) => void
}) {
if (items.length === 0) return null
return (
<Command.Group heading={heading} className={GROUP_HEADING_CLASSNAME}>
{items.map((item) => (
<MemoizedIconItem
key={item.id}
value={`${item.name} ${item.folderPath?.join(' / ') ?? ''} ${prefix}-${item.id}`}
onSelect={() => onSelect(item)}
name={item.name}
icon={icon}
folderPath={item.folderPath}
/>
))}
</Command.Group>
)
})
}
@@ -0,0 +1,721 @@
/**
* @vitest-environment jsdom
*/
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage'
import {
MOTHERSHIP_SEND_MESSAGE_EVENT,
type MothershipSendMessageDetail,
} from '@/lib/mothership/events'
import { SearchModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal'
const { mockPush, mockSearchState } = vi.hoisted(() => ({
mockPush: vi.fn(),
mockSearchState: {
data: {
blocks: [] as unknown[],
tools: [] as unknown[],
triggers: [] as unknown[],
toolOperations: [] as unknown[],
isInitialized: true,
},
},
}))
vi.mock('next/navigation', () => ({
useParams: () => ({ workspaceId: 'workspace-1', workflowId: 'workflow-1' }),
useRouter: () => ({ push: mockPush }),
}))
vi.mock('posthog-js/react', () => ({
usePostHog: () => ({}),
}))
vi.mock('@/lib/core/config/env-flags', () => ({
isChatEnabled: true,
}))
vi.mock('@/lib/posthog/client', () => ({
captureEvent: vi.fn(),
}))
vi.mock('@/app/workspace/[workspaceId]/providers/global-commands-provider', () => ({
useInvokeGlobalCommand: () => vi.fn(),
}))
vi.mock('@/lib/workflows/triggers/trigger-utils', () => ({
hasTriggerCapability: () => false,
}))
vi.mock('@/stores/modals/search/store', () => ({
useSearchModalStore: Object.assign(
(selector: (state: typeof mockSearchState) => unknown) => selector(mockSearchState),
{ getState: () => mockSearchState }
),
}))
vi.mock('@/app/workspace/[workspaceId]/w/components/sidebar/sidebar', () => ({
SIDEBAR_SCROLL_EVENT: 'sidebar-scroll-to-item',
}))
vi.mock('@/hooks/use-permission-config', () => ({
usePermissionConfig: () => ({
config: {
hideIntegrationsTab: false,
hideTablesTab: false,
hideFilesTab: false,
hideKnowledgeBaseTab: false,
},
}),
}))
vi.mock('@/hooks/use-settings-navigation', () => ({
useSettingsNavigation: () => ({ navigateToSettings: vi.fn() }),
}))
async function enterSearchQuery(query: string): Promise<void> {
const input = document.querySelector<HTMLInputElement>('input[aria-label="Search anything"]')
if (!input) throw new Error('Search input not found')
await act(async () => {
const valueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
'value'
)?.set
valueSetter?.call(input, query)
input.dispatchEvent(new Event('input', { bubbles: true }))
})
}
describe('SearchModal', () => {
let container: HTMLDivElement
let root: Root
let originalScrollIntoView: typeof HTMLElement.prototype.scrollIntoView
beforeEach(() => {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
localStorage.clear()
mockPush.mockClear()
window.history.replaceState({}, '', '/workspace/workspace-1/w/workflow-1')
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
unobserve() {}
disconnect() {}
}
)
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
callback(0)
return 1
})
originalScrollIntoView = HTMLElement.prototype.scrollIntoView
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
configurable: true,
value: vi.fn(),
})
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(() => {
act(() => root.unmount())
container.remove()
document.querySelectorAll('[role="dialog"]').forEach((dialog) => dialog.remove())
if (originalScrollIntoView) {
HTMLElement.prototype.scrollIntoView = originalScrollIntoView
} else {
Reflect.deleteProperty(HTMLElement.prototype, 'scrollIntoView')
}
vi.unstubAllGlobals()
})
it('toggles ask mode with Tab and hands the query to Sim on Enter', async () => {
const onOpenChange = vi.fn()
await act(async () => {
root.render(<SearchModal open onOpenChange={onOpenChange} />)
})
await enterSearchQuery('plan our Slack launch week')
const input = document.querySelector<HTMLInputElement>('input[aria-label="Search anything"]')
act(() => {
input?.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true })
)
})
const askRow = document.querySelector<HTMLElement>('[cmdk-item]')
expect(document.querySelectorAll('[cmdk-item]')).toHaveLength(1)
expect(askRow?.textContent).toBe('New Chat: plan our Slack launch week')
expect(askRow?.getAttribute('aria-selected')).toBe('true')
act(() => {
document
.querySelector<HTMLInputElement>('input[aria-label="Ask Sim"]')
?.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })
)
})
expect(onOpenChange).toHaveBeenCalledWith(false)
expect(mockPush).toHaveBeenCalledWith('/workspace/workspace-1/home')
expect(MothershipHandoffStorage.consume('workspace-1')).toEqual({
message: 'plan our Slack launch week',
contexts: [],
})
})
it('returns to search results when Tab is pressed again in ask mode', async () => {
await act(async () => {
root.render(
<SearchModal
open
onOpenChange={vi.fn()}
workflows={[
{
id: 'workflow-rainbow',
name: 'Rainbow workflow',
href: '/workspace/workspace-1/w/workflow-rainbow',
},
]}
/>
)
})
await enterSearchQuery('rainbow')
const input = document.querySelector<HTMLInputElement>('input[aria-label="Search anything"]')
act(() => {
input?.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true })
)
})
expect(document.querySelector('[cmdk-item]')?.textContent).toBe('New Chat: rainbow')
act(() => {
document
.querySelector<HTMLInputElement>('input[aria-label="Ask Sim"]')
?.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true })
)
})
expect(document.querySelector('[cmdk-item]')?.textContent).toContain('Rainbow workflow')
})
it('puts the page itself first for an exact page-name query, then its contents', async () => {
const logs = [
{
id: 'log-1',
name: 'Billing sync',
href: '/workspace/workspace-1/logs?executionId=e1',
date: 'Aug 8, 1:00 PM',
},
{
id: 'log-2',
name: 'Onboarding',
href: '/workspace/workspace-1/logs?executionId=e2',
date: 'Aug 8, 2:00 PM',
},
]
await act(async () => {
root.render(<SearchModal open onOpenChange={vi.fn()} logs={logs} />)
})
await enterSearchQuery('Logs')
const rows = Array.from(document.querySelectorAll<HTMLElement>('[cmdk-item]')).map(
(el) => el.textContent ?? ''
)
expect(rows[0]).toContain('Logs')
expect(rows[1]).toContain('Billing sync')
expect(rows[2]).toContain('Onboarding')
})
it('puts Create workflow first for the module-name query, then the workflows', async () => {
const workflows = [
{ id: 'workflow-a', name: 'Alpha', href: '/workspace/workspace-1/w/workflow-a' },
{ id: 'workflow-b', name: 'Beta', href: '/workspace/workspace-1/w/workflow-b' },
]
await act(async () => {
root.render(
<SearchModal
open
onOpenChange={vi.fn()}
canEdit
onCreateWorkflow={vi.fn()}
workflows={workflows}
/>
)
})
await enterSearchQuery('workflows')
const rows = Array.from(document.querySelectorAll<HTMLElement>('[cmdk-item]')).map(
(el) => el.textContent ?? ''
)
expect(rows[0]).toContain('Create workflow')
expect(rows[1]).toContain('Alpha')
expect(rows[2]).toContain('Beta')
})
it('shows an empty state when search has no results', async () => {
await act(async () => {
root.render(<SearchModal open onOpenChange={vi.fn()} />)
})
await enterSearchQuery('explain quantum rainbows')
expect(document.querySelectorAll('[cmdk-item]')).toHaveLength(0)
expect(document.querySelector('[cmdk-empty]')?.textContent).toBe('No results found.')
})
it('sends the query directly when the new-chat surface is already mounted', async () => {
window.history.replaceState({}, '', '/workspace/workspace-1/home')
const receivedMessages: string[] = []
const handleMessage = (event: Event) => {
receivedMessages.push((event as CustomEvent<MothershipSendMessageDetail>).detail.message)
event.preventDefault()
}
window.addEventListener(MOTHERSHIP_SEND_MESSAGE_EVENT, handleMessage)
try {
await act(async () => {
root.render(<SearchModal open onOpenChange={vi.fn()} />)
})
await enterSearchQuery('summarize this workspace')
act(() => {
document
.querySelector<HTMLInputElement>('input[aria-label="Search anything"]')
?.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true })
)
})
act(() => {
document.querySelector<HTMLElement>('[cmdk-item]')?.click()
})
expect(receivedMessages).toEqual(['summarize this workspace'])
expect(mockPush).not.toHaveBeenCalled()
expect(MothershipHandoffStorage.consume('workspace-1')).toBeNull()
} finally {
window.removeEventListener(MOTHERSHIP_SEND_MESSAGE_EVENT, handleMessage)
}
})
it('puts the Start Trigger first when the query is its exact name', async () => {
const Icon = () => null
const original = { ...mockSearchState.data }
mockSearchState.data = {
...mockSearchState.data,
triggers: [{ id: 'start', name: 'Start', icon: Icon, bgColor: '#111', type: 'start' }],
toolOperations: [
{
id: 'browser_start_task',
name: 'Start Task',
serviceName: 'Browser',
searchValue: 'browser start-task',
icon: Icon,
bgColor: '#611f69',
blockType: 'browser',
operationId: 'start_task',
},
],
}
const workflows = [
{ id: 'workflow-start', name: 'Start', href: '/workspace/workspace-1/w/workflow-start' },
]
try {
await act(async () => {
root.render(
<SearchModal open onOpenChange={vi.fn()} pageContext='workflow' workflows={workflows} />
)
})
await enterSearchQuery('start')
const rows = Array.from(document.querySelectorAll<HTMLElement>('[cmdk-item]')).map(
(el) => el.textContent ?? ''
)
expect(rows[0]).toContain('Start Trigger')
expect(rows.some((row) => row.includes('Start Task'))).toBe(true)
} finally {
mockSearchState.data = original
}
})
it('puts the workflow verb actions first for their bare-verb queries', async () => {
const Icon = () => null
const original = { ...mockSearchState.data }
mockSearchState.data = {
...mockSearchState.data,
toolOperations: [
{
id: 'vercel_deploy',
name: 'Deploy',
serviceName: 'Vercel',
searchValue: 'vercel deploy',
icon: Icon,
bgColor: '#000',
blockType: 'vercel',
operationId: 'deploy',
},
{
id: 'sheets_copy',
name: 'Copy',
serviceName: 'Sheets',
searchValue: 'sheets copy',
icon: Icon,
bgColor: '#0f9d58',
blockType: 'sheets',
operationId: 'copy',
},
],
}
try {
await act(async () => {
root.render(<SearchModal open onOpenChange={vi.fn()} pageContext='workflow' canAdmin />)
})
await enterSearchQuery('deploy')
let rows = Array.from(document.querySelectorAll<HTMLElement>('[cmdk-item]')).map(
(el) => el.textContent ?? ''
)
expect(rows[0]).toContain('Deploy workflow')
expect(rows.some((row) => row.includes('Vercel'))).toBe(true)
await enterSearchQuery('copy')
rows = Array.from(document.querySelectorAll<HTMLElement>('[cmdk-item]')).map(
(el) => el.textContent ?? ''
)
expect(rows[0]).toContain('Copy workflow link')
expect(rows.some((row) => row.includes('Sheets'))).toBe(true)
} finally {
mockSearchState.data = original
}
})
it('browses every section uncapped', async () => {
const Icon = () => null
const workflows = Array.from({ length: 10 }, (_, index) => ({
id: `workflow-${index}`,
name: `Zeta ${index}`,
href: `/workspace/workspace-1/w/workflow-${index}`,
}))
const integrations = Array.from({ length: 30 }, (_, index) => ({
id: `catalog-${index}`,
name: `Acme ${index}`,
href: `/workspace/workspace-1/integrations/catalog-${index}`,
icon: Icon,
bgColor: '#111',
}))
await act(async () => {
root.render(
<SearchModal
open
onOpenChange={vi.fn()}
integrations={integrations}
workflows={workflows}
/>
)
})
const rows = Array.from(document.querySelectorAll<HTMLElement>('[cmdk-item]')).map(
(el) => el.textContent ?? ''
)
expect(rows.filter((row) => /Zeta \d/.test(row))).toHaveLength(10)
expect(rows.filter((row) => /Acme \d/.test(row))).toHaveLength(30)
})
it('ranks a matched action above an exact-named entity from another section', async () => {
const workflows = [
{ id: 'workflow-run', name: 'Run', href: '/workspace/workspace-1/w/workflow-run' },
]
await act(async () => {
root.render(
<SearchModal open onOpenChange={vi.fn()} pageContext='workflow' workflows={workflows} />
)
})
await enterSearchQuery('run')
const rows = Array.from(document.querySelectorAll<HTMLElement>('[cmdk-item]')).map(
(el) => el.textContent ?? ''
)
expect(rows[0]).toContain('Run workflow')
expect(rows.some((row) => row.includes('Run') && !row.includes('Run workflow'))).toBe(true)
})
it('orders canvas browse groups as Actions, Sim, building blocks, then the standard tail', async () => {
const Icon = () => null
const block = { id: 'agent', name: 'Agent', icon: Icon, bgColor: '#111', type: 'agent' }
const original = { ...mockSearchState.data }
mockSearchState.data = {
...mockSearchState.data,
blocks: [block],
triggers: [{ ...block, id: 'schedule', name: 'Schedule', type: 'schedule' }],
tools: [{ ...block, id: 'slack', name: 'Slack', type: 'slack' }],
toolOperations: [
{
id: 'slack_send_message',
name: 'Send Message',
serviceName: 'Slack',
searchValue: 'slack send-message',
icon: Icon,
bgColor: '#611f69',
blockType: 'slack',
operationId: 'send_message',
},
],
}
const workflows = [
{ id: 'workflow-a', name: 'Alpha workflow', href: '/workspace/workspace-1/w/workflow-a' },
]
const integrations = [
{
id: 'slack-int',
name: 'Slack',
href: '/integrations/slack',
icon: Icon,
bgColor: '#611f69',
},
]
try {
await act(async () => {
root.render(
<SearchModal
open
onOpenChange={vi.fn()}
pageContext='workflow'
workflows={workflows}
integrations={integrations}
connectedAccounts={integrations}
/>
)
})
const headings = Array.from(
document.querySelectorAll<HTMLElement>('[cmdk-group-heading]')
).map((el) => el.textContent)
expect(headings.slice(0, 7)).toEqual([
'Actions',
'Sim',
'Blocks',
'Triggers',
'Tools',
'Pages',
'Workflows',
])
expect(headings).not.toContain('Tool operations')
expect(headings).not.toContain('Integrations')
expect(headings).not.toContain('Connected Integrations')
} finally {
mockSearchState.data = original
}
})
it('hoists a module pages actions and its entity section directly under the Sim group', async () => {
const tables = [{ id: 'table-1', name: 'Leads', href: '/workspace/workspace-1/tables/table-1' }]
await act(async () => {
root.render(
<SearchModal open onOpenChange={vi.fn()} pageContext='tables' canEdit tables={tables} />
)
})
const headings = Array.from(document.querySelectorAll<HTMLElement>('[cmdk-group-heading]')).map(
(el) => el.textContent
)
expect(headings.slice(0, 4)).toEqual(['Actions', 'Sim', 'Tables', 'Pages'])
})
it('browses the integrations catalog from every page', async () => {
const Icon = () => null
const integrations = [
{ id: 'slack', name: 'Slack', href: '/integrations/slack', icon: Icon, bgColor: '#611f69' },
]
await act(async () => {
root.render(<SearchModal open onOpenChange={vi.fn()} integrations={integrations} />)
})
const headings = Array.from(document.querySelectorAll<HTMLElement>('[cmdk-group-heading]')).map(
(el) => el.textContent
)
expect(headings).toContain('Integrations')
await enterSearchQuery('slack')
const rows = Array.from(document.querySelectorAll<HTMLElement>('[cmdk-item]')).map(
(el) => el.textContent
)
expect(rows.some((text) => text?.includes('Slack'))).toBe(true)
})
it('re-anchors selection to the first row on every open', async () => {
const workflows = [
{ id: 'workflow-a', name: 'Alpha workflow', href: '/workspace/workspace-1/w/workflow-a' },
{ id: 'workflow-b', name: 'Beta workflow', href: '/workspace/workspace-1/w/workflow-b' },
]
await act(async () => {
root.render(<SearchModal open onOpenChange={vi.fn()} workflows={workflows} />)
})
const input = document.querySelector<HTMLInputElement>('input[aria-label="Search anything"]')
act(() => {
input?.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true })
)
})
const rows = () => Array.from(document.querySelectorAll<HTMLElement>('[cmdk-item]'))
expect(rows()[1]?.getAttribute('aria-selected')).toBe('true')
await act(async () => {
root.render(<SearchModal open={false} onOpenChange={vi.fn()} workflows={workflows} />)
})
await act(async () => {
root.render(<SearchModal open onOpenChange={vi.fn()} workflows={workflows} />)
})
expect(rows()[0]?.getAttribute('aria-selected')).toBe('true')
expect(rows()[1]?.getAttribute('aria-selected')).toBe('false')
})
it('unmounts while closed and reopens with a blank query', async () => {
await act(async () => {
root.render(<SearchModal open onOpenChange={vi.fn()} />)
})
await enterSearchQuery('previous search')
act(() => {
document
.querySelector<HTMLInputElement>('input[aria-label="Search anything"]')
?.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true })
)
})
expect(document.querySelector('input[aria-label="Ask Sim"]')).not.toBeNull()
await act(async () => {
root.render(<SearchModal open={false} onOpenChange={vi.fn()} />)
})
expect(document.querySelector('[role="dialog"]')).toBeNull()
expect(document.querySelectorAll('[cmdk-item]')).toHaveLength(0)
await act(async () => {
root.render(<SearchModal open onOpenChange={vi.fn()} />)
})
const input = document.querySelector<HTMLInputElement>('input[aria-label="Search anything"]')
expect(input?.value).toBe('')
expect(document.querySelector('input[aria-label="Ask Sim"]')).toBeNull()
})
it('hides tool operations in browse but keeps them searchable', async () => {
const Icon = () => null
const original = { ...mockSearchState.data }
mockSearchState.data = {
...mockSearchState.data,
toolOperations: Array.from({ length: 75 }, (_, index) => ({
id: `service_operation_${index}`,
name: `Operation ${index}`,
serviceName: 'Service',
searchValue: `service operation-${index}`,
icon: Icon,
bgColor: '#111',
blockType: 'service',
operationId: `operation_${index}`,
})),
}
try {
await act(async () => {
root.render(<SearchModal open onOpenChange={vi.fn()} pageContext='workflow' />)
})
const browseRows = Array.from(document.querySelectorAll<HTMLElement>('[cmdk-item]')).filter(
(row) => row.textContent?.includes('Operation')
)
expect(browseRows).toHaveLength(0)
await enterSearchQuery('Operation')
expect(document.querySelectorAll('[cmdk-item]')).toHaveLength(50)
await enterSearchQuery('Operation 74')
const exactRows = Array.from(document.querySelectorAll<HTMLElement>('[cmdk-item]'))
expect(exactRows.some((row) => row.textContent?.includes('Operation 74'))).toBe(true)
} finally {
mockSearchState.data = original
}
})
it('does not offer deploy to workflow users without admin access', async () => {
await act(async () => {
root.render(
<SearchModal open onOpenChange={vi.fn()} pageContext='workflow' canEdit canAdmin={false} />
)
})
expect(document.body.textContent).not.toContain('Deploy workflow')
await act(async () => {
root.render(
<SearchModal open onOpenChange={vi.fn()} pageContext='workflow' canEdit canAdmin />
)
})
expect(document.body.textContent).toContain('Deploy workflow')
})
it('does not duplicate the Trigger suffix', async () => {
const Icon = () => null
const original = { ...mockSearchState.data }
mockSearchState.data = {
...mockSearchState.data,
triggers: [
{
id: 'generic_webhook',
name: 'Webhook Trigger',
icon: Icon,
bgColor: '#111',
type: 'generic_webhook',
},
],
}
try {
await act(async () => {
root.render(<SearchModal open onOpenChange={vi.fn()} pageContext='workflow' />)
})
expect(document.body.textContent).toContain('Webhook Trigger')
expect(document.body.textContent).not.toContain('Webhook Trigger Trigger')
} finally {
mockSearchState.data = original
}
})
it('keeps the palette open when the query handoff cannot be persisted', async () => {
const onOpenChange = vi.fn()
const storeSpy = vi.spyOn(MothershipHandoffStorage, 'store').mockReturnValue(false)
try {
await act(async () => {
root.render(<SearchModal open onOpenChange={onOpenChange} />)
})
await enterSearchQuery('draft a launch plan')
act(() => {
document
.querySelector<HTMLInputElement>('input[aria-label="Search anything"]')
?.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true })
)
})
act(() => {
document.querySelector<HTMLElement>('[cmdk-item]')?.click()
})
expect(onOpenChange).not.toHaveBeenCalled()
expect(mockPush).not.toHaveBeenCalled()
} finally {
storeSpy.mockRestore()
}
})
})
@@ -2,7 +2,221 @@
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { filterAndCap, filterAndSort, fuzzyMatch, MAX_RESULTS_PER_GROUP } from './utils'
import {
ACTION_MATCH_BIAS,
filterAndCap,
filterAndSort,
fuzzyMatch,
getActionGroupLabel,
getGlobalSearchResults,
MAX_RESULTS_PER_GROUP,
type SearchEntry,
scoreActions,
scoreAndSort,
scoreSectionItems,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils'
describe('getActionGroupLabel', () => {
const action = {
id: 'test-action',
name: 'Test action',
icon: () => null,
run: () => {},
}
it('separates page actions from Sim actions', () => {
expect(getActionGroupLabel({ ...action, context: 'workflow' })).toBe('Actions')
expect(getActionGroupLabel({ ...action, context: 'tables' })).toBe('Actions')
expect(getActionGroupLabel({ ...action, context: 'logsDashboard' })).toBe('Actions')
expect(getActionGroupLabel({ ...action, context: 'global' })).toBe('Sim')
})
it('lets an action group label surface actions whose names do not match', () => {
const workflowAction = {
...action,
name: 'Fit canvas to view',
context: 'workflow' as const,
}
expect(scoreActions([workflowAction], 'actions', 50, 'Actions')).toHaveLength(1)
expect(scoreActions([workflowAction], 'platform', 50, 'Actions')).toHaveLength(0)
})
})
describe('getGlobalSearchResults', () => {
it('merge-ranks results across every visible section', () => {
const action: SearchEntry = {
section: 'actions',
score: 7,
item: {
id: 'create-folder',
name: 'Create folder',
icon: () => null,
context: 'global',
run: () => {},
},
}
const workflow: SearchEntry = {
section: 'workflows',
score: 20,
item: { id: 'workflow-1', name: 'New customer workflow', href: '/workflow-1' },
}
const chat: SearchEntry = {
section: 'chats',
score: 83,
item: { id: 'chat-1', name: 'New chat', href: '/chat-1' },
}
const matches = getGlobalSearchResults(
{ actions: [action], workflows: [workflow], chats: [chat] },
['actions', 'workflows', 'chats']
)
expect(matches.map((entry) => entry.item.id)).toEqual(['chat-1', 'workflow-1', 'create-folder'])
})
it('biases matched actions above equal-quality matches from entity sections', () => {
const action = {
id: 'new-chat-action',
name: 'New chat',
keywords: 'message conversation',
icon: () => null,
context: 'global' as const,
run: () => {},
}
const chat = { id: 'new-chat-result', name: 'New chat', href: '/new-chat-result' }
const [actionMatch] = scoreActions([action], 'new c')
const [chatMatch] = scoreAndSort([chat], (item) => item.name, 'new c')
expect(actionMatch.score).toBe(chatMatch.score + ACTION_MATCH_BIAS)
expect(
getGlobalSearchResults(
{
actions: [{ section: 'actions', ...actionMatch }],
chats: [{ section: 'chats', ...chatMatch }],
},
['actions', 'chats']
).map((entry) => entry.item.id)
).toEqual(['new-chat-action', 'new-chat-result'])
})
it('breaks identical visible-name matches by the original section order', () => {
const workflow = { id: 'new-chat-workflow', name: 'New chat', href: '/new-chat-workflow' }
const chat = { id: 'new-chat-result', name: 'New chat', href: '/new-chat-result' }
const [workflowMatch] = scoreAndSort([workflow], (item) => item.name, 'new c')
const [chatMatch] = scoreAndSort([chat], (item) => item.name, 'new c')
expect(workflowMatch.score).toBe(chatMatch.score)
expect(
getGlobalSearchResults(
{
workflows: [{ section: 'workflows', ...workflowMatch }],
chats: [{ section: 'chats', ...chatMatch }],
},
['workflows', 'chats']
).map((entry) => entry.item.id)
).toEqual(['new-chat-workflow', 'new-chat-result'])
})
it('keeps every matching entry in score order', () => {
const workflows: SearchEntry[] = Array.from({ length: 8 }, (_, index) => ({
section: 'workflows',
score: index,
item: { id: `workflow-${index}`, name: `Workflow ${index}`, href: `/workflow-${index}` },
}))
expect(
getGlobalSearchResults({ workflows }, ['workflows']).map((entry) => entry.item.id)
).toEqual([
'workflow-7',
'workflow-6',
'workflow-5',
'workflow-4',
'workflow-3',
'workflow-2',
'workflow-1',
'workflow-0',
])
})
})
describe('scoreSectionItems', () => {
it("surfaces a section's items when the query matches the section name", () => {
const chats = [{ name: 'Quarterly planning' }, { name: 'Incident follow-up' }]
expect(scoreSectionItems('chats', chats, (chat) => chat.name, 'Chats')).toEqual([
{ item: chats[0], score: expect.any(Number) },
{ item: chats[1], score: expect.any(Number) },
])
})
it('keeps direct matches first and preserves natural fallback order', () => {
const workspaces = [
{ name: 'Workspaces demo', keywords: 'long metadata' },
{ name: 'Acme', keywords: 'a much longer metadata value' },
{ name: 'Beta', keywords: '' },
]
expect(
scoreSectionItems(
'workspaces',
workspaces,
(workspace) => workspace.name,
'workspaces',
(workspace) => workspace.keywords
).map(({ item }) => item.name)
).toEqual(['Workspaces demo', 'Acme', 'Beta'])
})
it('never fills or lifts tool operations from their section label', () => {
const operations = [{ name: 'Send Message' }, { name: 'Create Row' }]
expect(
scoreSectionItems('toolOperations', operations, (op) => op.name, 'tool operations')
).toHaveLength(0)
expect(scoreSectionItems('toolOperations', operations, (op) => op.name, 'tool')).toHaveLength(0)
expect(
scoreSectionItems('toolOperations', operations, (op) => op.name, 'send').map(
({ item }) => item.name
)
).toEqual(['Send Message'])
})
it('lifts a whole section above other sections name matches when the query is exactly its name', () => {
const workflowItems = [
{ name: 'Onboarding' },
{ name: 'Billing sync' },
{ name: 'Workflow QA' },
]
const sectionScores = scoreSectionItems(
'workflows',
workflowItems,
(item) => item.name,
'workflows'
)
const [chatMatch] = scoreAndSort(
[{ name: 'Workflows retro' }],
(item) => item.name,
'workflows'
)
expect(sectionScores).toHaveLength(3)
expect(sectionScores.every(({ score }) => score > chatMatch.score)).toBe(true)
})
it('does not lift a section for a partial section-name query', () => {
const workflowItems = [{ name: 'Onboarding' }]
const [sectionFill] = scoreSectionItems(
'workflows',
workflowItems,
(item) => item.name,
'workflow'
)
const [chatMatch] = scoreAndSort([{ name: 'Workflow retro' }], (item) => item.name, 'workflow')
expect(sectionFill.score).toBeLessThan(chatMatch.score)
})
})
/**
* The matcher that shipped before fuzzy matching was introduced. Re-implemented
@@ -294,6 +508,72 @@ describe('filterAndSort — name ranked above secondary text', () => {
})
})
describe('secondary-text matching — no scattered noise', () => {
it('does not scatter-match a query across long unrelated secondary text', () => {
const items = [{ name: 'Write Contact', extra: 'Wealthbox Write Contact match snap up' }]
expect(fuzzyMatch(items[0].extra, 'whatsapp').matched).toBe(true)
expect(
filterAndSort(
items,
(item) => item.name,
'whatsapp',
(item) => item.extra
)
).toEqual([])
})
it('still matches secondary text by substring and by whole tokens', () => {
const items = [{ name: 'Send Message', extra: 'Slack Send Message dm chat' }]
expect(
filterAndSort(
items,
(item) => item.name,
'slack',
(item) => item.extra
)
).toHaveLength(1)
expect(
filterAndSort(
items,
(item) => item.name,
'slack chat',
(item) => item.extra
)
).toHaveLength(1)
expect(
filterAndSort(
items,
(item) => item.name,
'whatsapp',
(item) => item.extra
)
).toHaveLength(0)
})
it('scatter-matches within a single kebab-cased entry but never across entries', () => {
const items = [{ name: 'Do Thing', extra: 'slack send-message dm chat' }]
expect(
filterAndSort(
items,
(item) => item.name,
'sndmsg',
(item) => item.extra
)
).toHaveLength(1)
expect(
filterAndSort(
items,
(item) => item.name,
'dmchat',
(item) => item.extra
)
).toHaveLength(0)
})
})
describe('filterAndCap', () => {
const id = (s: string) => s
@@ -1,4 +1,39 @@
import type { ComponentType } from 'react'
import { toSearchToken } from '@/lib/search/tokens'
import type { SearchBlockItem, SearchToolOperationItem } from '@/stores/modals/search/types'
/**
* Every result group the palette can render. This is also the canonical order:
* the zero-query browse list and the flat search tie-break both follow it,
* with two page-aware insertions at the front the page's action group, then
* its own entity section hoisted above `actions`' platform group.
*/
export const SEARCH_SECTIONS = [
'actions',
'blocks',
'triggers',
'tools',
'toolOperations',
'pages',
'workflows',
'workspaces',
'files',
'tables',
'knowledgeBases',
'logs',
'connectedAccounts',
'chats',
'integrations',
] as const
/** A single search-modal result group. */
export type SearchSection = (typeof SEARCH_SECTIONS)[number]
/**
* Canvas building-block sections. They render between the page's action group
* and the Sim group; off the canvas they carry no items and render nothing.
*/
export const CANVAS_SECTIONS = ['blocks', 'triggers', 'tools', 'toolOperations'] as const
export interface IntegrationSearchItem {
id: string
@@ -12,6 +47,8 @@ export interface TaskItem {
id: string
name: string
href: string
/** Formatted last-activity date shown as trailing metadata. Set for chats. */
date?: string
}
/**
@@ -19,6 +56,7 @@ export interface TaskItem {
* folder it came from a name is only unique within its folder.
*/
export interface FolderedItem extends TaskItem {
/** Owning folder names, root first. */
folderPath?: string[]
}
@@ -31,6 +69,8 @@ export interface WorkspaceItem {
name: string
href: string
isCurrent?: boolean
logoUrl?: string | null
color?: string
}
export interface PageItem {
@@ -45,8 +85,33 @@ export interface PageItem {
export type FileItem = FolderedItem
export interface LogItem {
id: string
/** Workflow (or job) name the execution belongs to. */
name: string
href: string
/** Human-readable run date shown as trailing metadata. */
date: string
}
/**
* Pages that contribute their own palette actions while active. Each page
* registers its handlers as global commands on mount; the palette invokes
* them by id and only offers them while the matching route is mounted.
*/
export type PageActionContext =
| 'workflow'
| 'tables'
| 'tableDetail'
| 'files'
| 'fileDetail'
| 'knowledge'
| 'knowledgeBase'
| 'logs'
| 'logsDashboard'
/** Where an {@link ActionItem} (a verb) is available. */
export type ActionContext = 'global' | 'workflow' | 'integrations'
export type ActionContext = 'global' | PageActionContext
/**
* An action is a verb the palette can run directly (create, import, toggle),
@@ -59,12 +124,42 @@ export interface ActionItem {
name: string
/** Extra terms folded into the search value (e.g. "new add"). */
keywords?: string
/**
* Lowercase queries that name this action outright the module it heads
* (`'workflows'` for Create workflow) or its bare verb (`'deploy'`,
* `'copy'`). When the trimmed query IS one of these, the action ranks like
* a page row ({@link PAGE_MATCH_TIER}), above section-lifted and
* exact-name entity rows.
*/
exactQueries?: readonly string[]
icon: ComponentType<{ className?: string }>
shortcut?: string
context: ActionContext
run: () => void
}
export type ActionGroupLabel = 'Sim' | 'Actions'
/**
* The page's own entity section, hoisted directly under its action group in
* both the browse list and the search tie-break.
*/
export const PAGE_CONTEXT_HOISTED_SECTION: Partial<Record<PageActionContext, SearchSection>> = {
tables: 'tables',
tableDetail: 'tables',
files: 'files',
fileDetail: 'files',
knowledge: 'knowledgeBases',
knowledgeBase: 'knowledgeBases',
logs: 'logs',
logsDashboard: 'logs',
}
/** Presentation group for an action without changing its stable result identity. */
export function getActionGroupLabel(action: ActionItem): ActionGroupLabel {
return action.context === 'global' ? 'Sim' : 'Actions'
}
export interface SearchModalProps {
open: boolean
onOpenChange: (open: boolean) => void
@@ -74,11 +169,13 @@ export interface SearchModalProps {
tables?: FolderedItem[]
files?: FileItem[]
knowledgeBases?: FolderedItem[]
logs?: LogItem[]
integrations?: IntegrationSearchItem[]
connectedAccounts?: IntegrationSearchItem[]
isOnWorkflowPage?: boolean
isOnIntegrationsPage?: boolean
/** Page the palette was opened on, when that page contributes actions. */
pageContext?: PageActionContext | null
canEdit?: boolean
canAdmin?: boolean
onCreateWorkflow?: () => void
onCreateFolder?: () => void
onImportWorkflow?: () => void
@@ -98,13 +195,86 @@ export interface CommandItemProps {
workflowType?: string
/** Primary text of the row. */
label: string
/** De-emphasized lead-in before the label (e.g. a tool operation's service). */
labelPrefix?: string
/** Right-aligned trailing metadata. */
meta?: string
}
export const SECTION_LABELS: Record<SearchSection, string> = {
actions: 'Sim',
blocks: 'Blocks',
triggers: 'Triggers',
tools: 'Tools',
toolOperations: 'Tool operations',
pages: 'Pages',
workflows: 'Workflows',
workspaces: 'Workspaces',
files: 'Files',
tables: 'Tables',
knowledgeBases: 'Knowledge Bases',
logs: 'Logs',
connectedAccounts: 'Connected Integrations',
integrations: 'Integrations',
chats: 'Chats',
}
export type SearchEntry =
| { section: 'actions'; score: number; item: ActionItem }
| { section: 'blocks' | 'tools' | 'triggers'; score: number; item: SearchBlockItem }
| { section: 'toolOperations'; score: number; item: SearchToolOperationItem }
| { section: 'connectedAccounts' | 'integrations'; score: number; item: IntegrationSearchItem }
| { section: 'chats'; score: number; item: TaskItem }
| { section: 'workflows'; score: number; item: WorkflowItem }
| { section: 'tables' | 'knowledgeBases'; score: number; item: FolderedItem }
| { section: 'files'; score: number; item: FileItem }
| { section: 'logs'; score: number; item: LogItem }
| { section: 'workspaces'; score: number; item: WorkspaceItem }
| { section: 'pages'; score: number; item: PageItem }
export interface SearchEntryHandlers {
onSelectAction: (item: ActionItem) => void
onSelectBlock: (item: SearchBlockItem) => void
onSelectTool: (item: SearchBlockItem) => void
onSelectTrigger: (item: SearchBlockItem) => void
onSelectToolOperation: (item: SearchToolOperationItem) => void
onSelectConnectedAccount: (item: IntegrationSearchItem) => void
onSelectIntegration: (item: IntegrationSearchItem) => void
onSelectChat: (item: TaskItem) => void
onSelectWorkflow: (item: WorkflowItem) => void
onSelectTable: (item: FolderedItem) => void
onSelectFile: (item: FileItem) => void
onSelectKnowledgeBase: (item: FolderedItem) => void
onSelectLog: (item: LogItem) => void
onSelectWorkspace: (item: WorkspaceItem) => void
onSelectPage: (item: PageItem) => void
}
/** Merge-ranks every match from the visible sections into one flat result list. */
export function getGlobalSearchResults(
entriesBySection: Partial<Record<SearchSection, readonly SearchEntry[]>>,
sections: readonly SearchSection[]
): SearchEntry[] {
/* Flattening in section order makes the spec-stable sort's tie-break the
section order (then within-section order) with no explicit comparator. */
return sections
.flatMap((section) => entriesBySection[section] ?? [])
.sort((a, b) => b.score - a.score)
}
/**
* `scroll-mt-12` mirrors the list's `pt-12`: the search input floats over the
* top 48px of the scrollport, and cmdk keeps the selection visible with
* `scrollIntoView({ block: 'nearest' })` without the scroll margin, arrowing
* upward (or loop-wrapping to the first row) parks the row under the input.
* Group headings need the same margin because cmdk scrolls the heading into
* view when the selection is its group's first row.
*/
export const GROUP_HEADING_CLASSNAME =
'[&_[cmdk-group-heading]]:flex [&_[cmdk-group-heading]]:h-[18px] [&_[cmdk-group-heading]]:items-center [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:mb-2 [&_[cmdk-group-heading]]:text-small [&_[cmdk-group-heading]]:text-[var(--text-muted)]'
'[&_[cmdk-group-heading]]:flex [&_[cmdk-group-heading]]:h-[18px] [&_[cmdk-group-heading]]:items-center [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:mb-2 [&_[cmdk-group-heading]]:scroll-mt-12 [&_[cmdk-group-heading]]:text-small [&_[cmdk-group-heading]]:text-[var(--text-muted)]'
export const COMMAND_ITEM_CLASSNAME =
'group mx-0.5 flex h-[30px] w-full cursor-pointer items-center gap-2 rounded-lg border border-transparent px-2 text-left text-sm aria-selected:border-[var(--border-1)] aria-selected:bg-[var(--surface-active)] data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50'
'group mx-0.5 flex h-[30px] w-full cursor-pointer items-center gap-2 rounded-lg border border-transparent px-2 text-left text-sm scroll-mt-12 scroll-mb-1.5 aria-selected:border-[var(--border-1)] aria-selected:bg-[var(--surface-active)] data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50'
/** Characters that begin a new word — a match here scores higher. */
const SEPARATORS = new Set([' ', '-', '_', '/', '.', ':', '(', ')'])
@@ -186,8 +356,18 @@ function tokenFallback(lowerText: string, lowerQuery: string): FuzzyResult {
*
* Contiguous substring matches report the indices of the substring itself
* rather than an earlier scattered occurrence of the same characters.
*
* Pass `scatter: false` to skip the scattered-subsequence mode. Over long
* multi-word text (alias lists, option labels) a scattered query matches
* almost anything "whatsapp" finds `w…h…a…t…s…a…p…p` across unrelated alias
* words so secondary-text matching keeps only the exact/prefix/substring
* and multi-word token modes.
*/
export function fuzzyMatch(text: string, query: string): FuzzyResult {
export function fuzzyMatch(
text: string,
query: string,
options?: { scatter?: boolean }
): FuzzyResult {
if (!query) return { matched: true, score: 1, positions: [] }
if (!text) return NO_MATCH
@@ -215,6 +395,8 @@ export function fuzzyMatch(text: string, query: string): FuzzyResult {
return { matched: true, score, positions }
}
if (options?.scatter === false) return tokenFallback(lowerText, lowerQuery)
const positions: number[] = []
let queryIndex = 0
let score = 0
@@ -248,22 +430,190 @@ export function fuzzyMatch(text: string, query: string): FuzzyResult {
/** Rank offset that lifts every name match above any secondary-text match. */
const NAME_MATCH_TIER = 1_000_000
/**
* Rank offset that lifts an entire section above every name match when the
* query IS that section's name typing "triggers" asks for the Triggers
* section itself, not rows from other sections that happen to contain the word.
*/
const SECTION_MATCH_TIER = 2_000_000
/**
* Rank offset for a page row whose name IS the query. Typing "logs" means the
* Logs page itself first, then its contents (the section lifted into
* {@link SECTION_MATCH_TIER}) beneath it.
*/
export const PAGE_MATCH_TIER = 3_000_000
/**
* Matches a query against secondary search text: a space-separated list of
* entries where multi-word phrases are kebab-cased into single tokens (see
* `toSearchToken`). Whole-string matching keeps the exact/prefix/substring and
* multi-word token modes; scattered matching runs against each entry
* individually, so a query can scatter within one entry ("sndmsg"
* "send-message") but never assemble itself across unrelated entries
* ("whatsapp" must not match "wealthbox-write-contact match snap up").
*/
/**
* Secondary-text strings are stable catalog data (block/tool/operation search
* values), so their word splits are cached the palette re-matches every
* miss on every keystroke, and re-splitting dominated that loop.
*/
const secondaryTextWords = new Map<string, string[]>()
function matchSecondaryText(extra: string, query: string): FuzzyResult {
const whole = fuzzyMatch(extra, query, { scatter: false })
let best = whole.matched ? whole : NO_MATCH
let words = secondaryTextWords.get(extra)
if (!words) {
words = extra.split(/\s+/)
secondaryTextWords.set(extra, words)
}
for (const word of words) {
const byWord = fuzzyMatch(word, query)
if (byWord.matched && (!best.matched || byWord.score > best.score)) best = byWord
}
return best
}
/**
* Ranks an item by its name first, falling back to secondary text (ids, aliases,
* option labels) only when the name doesn't match a name match always wins, so
* an exact name hit isn't diluted by a long secondary string ("Agent" beats
* "Pi Coding Agent" for the query "agent").
*/
function scoreItem(name: string, extra: string | undefined, search: string): FuzzyResult {
function scoreItem(name: string, search: string, getExtra?: () => string | undefined): FuzzyResult {
const byName = fuzzyMatch(name, search)
if (!extra) return byName
if (byName.matched) {
return { matched: true, score: byName.score + NAME_MATCH_TIER, positions: byName.positions }
}
const byExtra = fuzzyMatch(extra, search)
const extra = getExtra?.()
if (!extra) return NO_MATCH
const byExtra = matchSecondaryText(extra, search)
return byExtra.matched ? byExtra : NO_MATCH
}
/** Scores and sorts matches while retaining scores for cross-section ranking. */
export function scoreAndSort<T>(
items: T[],
toValue: (item: T) => string,
search: string,
toExtra?: (item: T) => string | undefined
): Array<{ item: T; score: number }> {
const query = search.trim()
const scored: Array<{ item: T; score: number }> = []
for (const item of items) {
const { matched, score } = scoreItem(
toValue(item),
query,
toExtra ? () => toExtra(item) : undefined
)
if (matched) scored.push({ item, score })
}
scored.sort((a, b) => b.score - a.score)
return scored
}
/**
* Scores normal item matches first, then fills a matched section with its
* remaining rows in natural order. A query that exactly names the section
* lifts every returned row into {@link SECTION_MATCH_TIER}, keeping this
* internal order but beating name matches from other sections.
*/
function scoreItemsForSection<T>(
sectionLabel: string,
items: T[],
toValue: (item: T) => string,
search: string,
toExtra?: (item: T) => string | undefined,
maxResults = Number.POSITIVE_INFINITY
): Array<{ item: T; score: number }> {
const rankedItems = scoreAndSort(items, toValue, search, toExtra)
const query = search.trim()
const sectionMatch = fuzzyMatch(sectionLabel, query)
const isExactLabelMatch =
sectionMatch.matched && query.toLowerCase() === sectionLabel.toLowerCase()
let results: Array<{ item: T; score: number }>
if (!sectionMatch.matched || rankedItems.length >= maxResults) {
results = rankedItems.slice(0, maxResults)
} else {
const matchedItems = new Set(rankedItems.map(({ item }) => item))
const lowestItemScore = rankedItems.at(-1)?.score
const fallbackScore =
lowestItemScore === undefined
? sectionMatch.score
: Math.min(sectionMatch.score, lowestItemScore - 1)
results = [...rankedItems]
for (const item of items) {
if (!matchedItems.has(item)) results.push({ item, score: fallbackScore })
if (results.length >= maxResults) break
}
}
if (isExactLabelMatch) {
return results.map(({ item }, index) => ({ item, score: SECTION_MATCH_TIER - index }))
}
return results
}
/**
* Sections whose label never participates in matching. Tool operations are a
* 1000+ registry-ordered list, so label-driven behavior ("tool operations"
* lifting the section, or a partial hit like "tool" filling it) would surface
* arbitrary rows; individual operations stay searchable by name and alias.
*/
const LABEL_MATCH_EXEMPT_SECTIONS = new Set<SearchSection>(['toolOperations'])
export function scoreSectionItems<T>(
section: SearchSection,
items: T[],
toValue: (item: T) => string,
search: string,
toExtra?: (item: T) => string | undefined,
maxResults = Number.POSITIVE_INFINITY
): Array<{ item: T; score: number }> {
if (LABEL_MATCH_EXEMPT_SECTIONS.has(section)) {
return scoreAndSort(items, toValue, search, toExtra).slice(0, maxResults)
}
return scoreItemsForSection(SECTION_LABELS[section], items, toValue, search, toExtra, maxResults)
}
/**
* Rank offset added to every matched action. Actions are the palette's few
* runnable verbs, so a matched action outranks entity rows of the same match
* quality a name-matched action beats name-matched entities, a
* keyword-matched action beats other secondary-text matches while the
* half-tier offset deliberately cannot bridge into the next tier up
* ({@link SECTION_MATCH_TIER}, {@link PAGE_MATCH_TIER}).
*/
export const ACTION_MATCH_BIAS = 500_000
/**
* Scores actions by visible name before falling back to their keywords.
* Every match is lifted by {@link ACTION_MATCH_BIAS}; a query listed in the
* action's `exactQueries` ranks it like a page row instead.
*/
export function scoreActions(
actions: ActionItem[],
search: string,
maxResults = Number.POSITIVE_INFINITY,
groupLabel: ActionGroupLabel = 'Sim'
): Array<{ item: ActionItem; score: number }> {
const query = search.trim().toLowerCase()
return scoreItemsForSection(
groupLabel,
actions,
(action) => action.name,
search,
(action) => `${toSearchToken(action.name)} ${action.keywords ?? ''}`,
maxResults
).map(({ item, score }) => ({
item,
score: item.exactQueries?.includes(query) ? PAGE_MATCH_TIER : score + ACTION_MATCH_BIAS,
}))
}
/**
* Filters and ranks items by fuzzy match, highest score first; returns the input
* unchanged when the search is empty or whitespace-only. Pass `toExtra` to rank
@@ -275,15 +625,8 @@ export function filterAndSort<T>(
search: string,
toExtra?: (item: T) => string | undefined
): T[] {
const query = search.trim()
if (!query) return items
const scored: Array<{ item: T; score: number }> = []
for (const item of items) {
const { matched, score } = scoreItem(toValue(item), toExtra?.(item), query)
if (matched) scored.push({ item, score })
}
scored.sort((a, b) => b.score - a.score)
return scored.map((entry) => entry.item)
if (!search.trim()) return items
return scoreAndSort(items, toValue, search, toExtra).map((entry) => entry.item)
}
/**
@@ -66,6 +66,10 @@ import {
buildConnectedAccountSearchItems,
buildIntegrationSearchItems,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/integration-search-items'
import type {
LogItem,
PageActionContext,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils'
import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu'
import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal'
import {
@@ -95,6 +99,7 @@ import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay'
import { useWorkspaceCredentials } from '@/hooks/queries/credentials'
import { useFolderMap, useFolders } from '@/hooks/queries/folders'
import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge'
import { type LogFilters, useLogsList } from '@/hooks/queries/logs'
import type { MothershipChatMetadata } from '@/hooks/queries/mothership-chats'
import {
useDeleteMothershipChat,
@@ -115,6 +120,7 @@ import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
import { SIDEBAR_WIDTH } from '@/stores/constants'
import { useFolderStore } from '@/stores/folders/store'
import type { WorkflowFolder } from '@/stores/folders/types'
import { useFilterStore } from '@/stores/logs/filters/store'
import { useSearchModalStore } from '@/stores/modals/search/store'
import { useProvidersStore } from '@/stores/providers'
import { useSettingsDirtyStore } from '@/stores/settings/dirty/store'
@@ -131,6 +137,27 @@ const EMPTY_CHATS: MothershipChatMetadata[] = []
/** Stable identity while a folder list loads, so the search-row memos don't churn. */
const EMPTY_FOLDER_MAP: Record<string, WorkflowFolder> = {}
/** Recent runs shown in the palette's Logs section on the logs pages. */
const SEARCH_MODAL_LOG_FILTERS: LogFilters = {
timeRange: 'All time',
level: 'all',
workflowIds: [],
folderIds: [],
triggers: [],
searchQuery: '',
limit: 50,
sortBy: 'date',
sortOrder: 'desc',
}
/** Short run/activity date for palette row receipts (logs, chats). */
const SEARCH_MODAL_DATE_FORMAT = new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
})
const SLACK_COMMUNITY_URL =
'https://join.slack.com/t/sim-ott9864/shared_invite/zt-43lp8tc5v-0qrrqHGBKUsvQlpoouH~TA'
@@ -424,7 +451,7 @@ export const Sidebar = memo(function Sidebar({
const posthog = usePostHog()
const { data: sessionData, isPending: sessionLoading } = useSession()
const { workspace: routeWorkspace } = useWorkspaceHostContext()
const { canEdit, isLoading: permissionsLoading } = useUserPermissionsContext()
const { canAdmin, canEdit, isLoading: permissionsLoading } = useUserPermissionsContext()
const {
config: permissionConfig,
filterBlocks,
@@ -771,6 +798,8 @@ export const Sidebar = memo(function Sidebar({
name: workspace.name,
href: `/workspace/${workspace.id}/w`,
isCurrent: workspace.id === workspaceId,
logoUrl: workspace.logoUrl,
color: workspace.color,
})),
[workspaces, workspaceId]
)
@@ -867,6 +896,7 @@ export const Sidebar = memo(function Sidebar({
fetchedChats.map((t) => ({
...t,
href: `/workspace/${workspaceId}/chat/${t.id}`,
date: SEARCH_MODAL_DATE_FORMAT.format(t.updatedAt),
})),
[fetchedChats, workspaceId]
)
@@ -1078,14 +1108,58 @@ export const Sidebar = memo(function Sidebar({
}, [])
const isOnSettingsPage = pathname?.startsWith(`/workspace/${workspaceId}/settings`) ?? false
const isOnIntegrationsPage =
pathname?.startsWith(`/workspace/${workspaceId}/integrations`) ?? false
const logsViewMode = useFilterStore((state) => state.viewMode)
/**
* Page whose registered palette commands are currently invocable. Matches
* only routes that mount the registering component: list pages exactly, and
* detail roots as a single path segment (deeper routes don't mount them).
*/
const searchModalPageContext = useMemo((): PageActionContext | null => {
if (!pathname) return null
if (workflowId) return 'workflow'
const base = `/workspace/${workspaceId}`
const detailSegment = (prefix: string): string | null => {
if (!pathname.startsWith(prefix)) return null
const rest = pathname.slice(prefix.length)
return rest && !rest.includes('/') ? rest : null
}
if (pathname === `${base}/tables`) return 'tables'
if (detailSegment(`${base}/tables/`)) return 'tableDetail'
if (pathname === `${base}/files`) return 'files'
if (detailSegment(`${base}/files/`)) return 'fileDetail'
if (pathname === `${base}/knowledge`) return 'knowledge'
if (detailSegment(`${base}/knowledge/`)) return 'knowledgeBase'
if (pathname === `${base}/logs`) return logsViewMode === 'dashboard' ? 'logsDashboard' : 'logs'
return null
}, [pathname, workspaceId, workflowId, logsViewMode])
const { data: fetchedCredentials = [] } = useWorkspaceCredentials({
workspaceId,
enabled: isOnIntegrationsPage && !permissionConfig.hideIntegrationsTab,
enabled:
isSearchModalOpen &&
!permissionConfig.hideIntegrationsTab &&
searchModalPageContext !== 'workflow',
})
const isOnLogsPage =
searchModalPageContext === 'logs' || searchModalPageContext === 'logsDashboard'
const logsPages = useLogsList(workspaceId, SEARCH_MODAL_LOG_FILTERS, {
enabled: isSearchModalOpen && isOnLogsPage,
})
const searchModalLogs = useMemo((): LogItem[] => {
const rows = logsPages.data?.pages[0]?.logs ?? []
return rows.map((log) => ({
id: log.id,
name: log.workflow?.name || log.jobTitle || 'Unknown workflow',
href: log.executionId
? `/workspace/${workspaceId}/logs?executionId=${log.executionId}`
: `/workspace/${workspaceId}/logs`,
date: SEARCH_MODAL_DATE_FORMAT.format(new Date(log.createdAt)),
}))
}, [logsPages.data, workspaceId])
const searchModalIntegrations = useMemo(
() =>
permissionConfig.hideIntegrationsTab
@@ -1297,7 +1371,8 @@ export const Sidebar = memo(function Sidebar({
{
id: 'open-search',
handler: () => {
openSearchModal()
const searchModal = useSearchModalStore.getState()
searchModal.setOpen(!searchModal.isOpen)
},
},
{
@@ -1851,11 +1926,12 @@ export const Sidebar = memo(function Sidebar({
tables={searchModalTables}
files={searchModalFiles}
knowledgeBases={searchModalKnowledgeBases}
logs={searchModalLogs}
integrations={searchModalIntegrations}
connectedAccounts={searchModalConnectedAccounts}
isOnWorkflowPage={!!workflowId}
isOnIntegrationsPage={isOnIntegrationsPage}
pageContext={searchModalPageContext}
canEdit={canEdit}
canAdmin={canAdmin}
onCreateWorkflow={handleCreateWorkflow}
onCreateFolder={handleCreateFolder}
onImportWorkflow={handleImportWorkflow}
+1 -3
View File
@@ -575,10 +575,8 @@ export interface PostHogEventMap {
| 'table'
| 'file'
| 'knowledge_base'
| 'log'
| 'page'
| 'docs'
| 'connected_account'
| 'integration'
| 'action'
query_length: number
workspace_id: string
+13
View File
@@ -0,0 +1,13 @@
/**
* Collapses a multi-word phrase into a single kebab-case search token.
*
* Secondary search text (aliases, service names, folder paths, option labels)
* is a space-separated list of entries, and the palette's scattered fuzzy
* matching is confined to one whitespace-delimited word at a time. Kebab-casing
* a multi-word entry keeps it scatter-matchable as a unit ("sndmsg" finds
* "send-message") while a query can never be assembled letter-by-letter across
* unrelated entries.
*/
export function toSearchToken(value: string): string {
return value.trim().split(/\s+/).join('-')
}
+2 -3
View File
@@ -69,7 +69,6 @@ describe('search modal store', () => {
tools: [],
triggers: [],
toolOperations: [],
docs: [],
isInitialized: false,
},
})
@@ -81,7 +80,7 @@ describe('search modal store', () => {
const searchValue = buildCommandSearchableOptionSearchValue(block)
expect(searchValue).toContain('Provider')
expect(searchValue).toContain('Fal.ai (Multi-Model)')
expect(searchValue).toContain('Fal.ai-(Multi-Model)')
expect(searchValue).toContain('falai')
expect(searchValue).not.toContain('Hidden Provider')
expect(searchValue).not.toContain('hidden')
@@ -144,7 +143,7 @@ describe('search modal store', () => {
expect(tools[0]).toEqual(
expect.objectContaining({
id: 'image_generator_v2',
searchValue: expect.stringContaining('Fal.ai (Multi-Model)'),
searchValue: expect.stringContaining('Fal.ai-(Multi-Model)'),
})
)
})
+13 -28
View File
@@ -1,6 +1,7 @@
import { Repeat, Split } from '@sim/emcn/icons'
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
import { toSearchToken } from '@/lib/search/tokens'
import { getToolOperationsIndex } from '@/lib/search/tool-operations'
import { getTriggersForSidebar } from '@/lib/workflows/triggers/trigger-utils'
import { getAllBlocks } from '@/blocks'
@@ -8,7 +9,6 @@ import type { BlockConfig, SubBlockConfig } from '@/blocks/types'
import type {
SearchBlockItem,
SearchData,
SearchDocItem,
SearchModalState,
SearchToolOperationItem,
} from './types'
@@ -18,7 +18,6 @@ const initialData: SearchData = {
tools: [],
triggers: [],
toolOperations: [],
docs: [],
isInitialized: false,
}
@@ -54,8 +53,8 @@ export function buildCommandSearchableOptionSearchValue(block: BlockConfig): str
if (option.hidden) continue
const subBlockTitle = subBlock.title ?? subBlock.id
terms.add(subBlockTitle)
terms.add(option.label)
terms.add(toSearchToken(subBlockTitle))
terms.add(toSearchToken(option.label))
terms.add(option.id)
}
}
@@ -67,24 +66,18 @@ export const useSearchModalStore = create<SearchModalState>()(
devtools(
(set, _) => ({
isOpen: false,
sections: null,
pendingConnect: null,
data: initialData,
setOpen: (open: boolean) => {
set({ isOpen: open, sections: null, pendingConnect: null })
set({ isOpen: open })
},
open: (options) => {
set({
isOpen: true,
sections: options?.sections ?? null,
pendingConnect: options?.pendingConnect ?? null,
})
open: () => {
set({ isOpen: true })
},
close: () => {
set({ isOpen: false, sections: null, pendingConnect: null })
set({ isOpen: false })
},
initializeData: (filterBlocks) => {
@@ -93,7 +86,6 @@ export const useSearchModalStore = create<SearchModalState>()(
const regularBlocks: SearchBlockItem[] = []
const tools: SearchBlockItem[] = []
const docs: SearchDocItem[] = []
for (const block of filteredAllBlocks) {
if (block.hideFromToolbar) continue
@@ -104,7 +96,7 @@ export const useSearchModalStore = create<SearchModalState>()(
icon: block.icon,
bgColor: block.bgColor || '#6B7280',
type: block.type,
searchValue: `${block.name} ${block.type} ${buildCommandSearchableOptionSearchValue(block)}`,
searchValue: `${toSearchToken(block.name)} ${block.type} ${buildCommandSearchableOptionSearchValue(block)}`,
sourceWorkflowId: block.sourceWorkflowId,
}
@@ -113,15 +105,6 @@ export const useSearchModalStore = create<SearchModalState>()(
} else if (block.category === 'tools') {
tools.push(searchItem)
}
if (block.docsLink) {
docs.push({
id: `docs-${block.type}`,
name: block.name,
icon: block.icon,
href: block.docsLink,
})
}
}
const specialBlocks: SearchBlockItem[] = [
@@ -176,11 +159,14 @@ export const useSearchModalStore = create<SearchModalState>()(
const toolOperations: SearchToolOperationItem[] = getToolOperationsIndex()
.filter((op) => allowedBlockTypes.has(op.blockType))
.map((op) => {
const aliasesStr = op.aliases?.length ? ` ${op.aliases.join(' ')}` : ''
const aliasesStr = op.aliases?.length
? ` ${op.aliases.map(toSearchToken).join(' ')}`
: ''
return {
id: op.id,
name: op.operationName,
searchValue: `${op.serviceName} ${op.operationName}${aliasesStr}`,
serviceName: op.serviceName,
searchValue: `${toSearchToken(op.serviceName)} ${toSearchToken(op.operationName)}${aliasesStr}`,
icon: op.icon,
bgColor: op.bgColor,
blockType: op.blockType,
@@ -194,7 +180,6 @@ export const useSearchModalStore = create<SearchModalState>()(
tools,
triggers,
toolOperations,
docs,
isInitialized: true,
},
})
+8 -65
View File
@@ -22,6 +22,7 @@ export interface SearchBlockItem {
export interface SearchToolOperationItem {
id: string
name: string
serviceName: string
searchValue: string
icon: ComponentType<{ className?: string }>
bgColor: string
@@ -29,16 +30,6 @@ export interface SearchToolOperationItem {
operationId: string
}
/**
* Represents a doc item in the search results.
*/
export interface SearchDocItem {
id: string
name: string
icon: ComponentType<{ className?: string }>
href: string
}
/**
* Pre-computed search data that is initialized on app load.
*/
@@ -47,37 +38,9 @@ export interface SearchData {
tools: SearchBlockItem[]
triggers: SearchBlockItem[]
toolOperations: SearchToolOperationItem[]
docs: SearchDocItem[]
isInitialized: boolean
}
/**
* Every result group the search modal can render, in render order. Used to
* restrict the palette to a subset of sections when opened for a specific
* intent (e.g. a drag-release that should only offer canvas-insertable items).
*/
export const SEARCH_SECTIONS = [
'actions',
'connectedAccounts',
'integrations',
'blocks',
'tools',
'triggers',
// Resource groups follow the sidebar's top-down order.
'chats',
'tables',
'files',
'knowledgeBases',
'workflows',
'toolOperations',
'workspaces',
'docs',
'pages',
] as const
/** A single search-modal result group. */
export type SearchSection = (typeof SEARCH_SECTIONS)[number]
/**
* Context handed to the palette when it is opened to complete an edge
* drag-release: the dragged source handle and the release point. A selection
@@ -95,43 +58,23 @@ export interface PendingConnect {
*
* Centralizing this state in a store allows any component (e.g. sidebar,
* workflow command list, keyboard shortcuts) to open or close the modal
* without relying on DOM events or prop drilling.
* without relying on DOM events or prop drilling. The pre-computed block data
* also feeds the canvas connection block selector.
*/
export interface SearchModalState {
/** Whether the search modal is currently open. */
isOpen: boolean
/**
* When set, the palette renders only these sections; `null` shows all of them.
*/
sections: SearchSection[] | null
/**
* Pending edge drag-release the palette was opened to complete. A selection
* stamps it onto its event; other add-block dispatchers carry none, so only a
* genuine palette pick completes the connection. `null` for ordinary opens.
*/
pendingConnect: PendingConnect | null
/** Pre-computed search data. */
/** Pre-computed block/tool search data (consumed by the canvas selector). */
data: SearchData
/**
* Explicitly set the open state of the modal. Always resets to the full
* palette (no section restriction, no pending connect).
*/
/** Explicitly set the open state of the modal. */
setOpen: (open: boolean) => void
/**
* Convenience method to open the modal. Pass `sections` to restrict the
* palette to a subset of result groups, and `pendingConnect` to complete an
* edge drag-release with the selection.
*/
open: (options?: { sections?: SearchSection[]; pendingConnect?: PendingConnect }) => void
/** Convenience method to open the modal. */
open: () => void
/**
* Convenience method to close the modal.
*/
/** Convenience method to close the modal. */
close: () => void
/**