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