diff --git a/web/app/components/goto-anything/actions/index.ts b/web/app/components/goto-anything/actions/index.ts index 7c091b92ef..f425a6f623 100644 --- a/web/app/components/goto-anything/actions/index.ts +++ b/web/app/components/goto-anything/actions/index.ts @@ -3,201 +3,60 @@ * * This file defines the action registry for the goto-anything search system. * Actions handle different types of searches: apps, knowledge bases, plugins, workflow nodes, and commands. - * - * ## How to Add a New Slash Command - * - * 1. **Create Command Handler File** (in `./commands/` directory): - * ```typescript - * // commands/my-command.ts - * import type { SlashCommandHandler } from './types' - * import type { CommandSearchResult } from '../types' - * import { registerCommands, unregisterCommands } from './command-bus' - * - * interface MyCommandDeps { - * myService?: (data: any) => Promise - * } - * - * export const myCommand: SlashCommandHandler = { - * name: 'mycommand', - * aliases: ['mc'], // Optional aliases - * description: 'My custom command description', - * - * async search(args: string, locale: string = 'en') { - * // Return search results based on args - * return [{ - * id: 'my-result', - * title: 'My Command Result', - * description: 'Description of the result', - * type: 'command' as const, - * data: { command: 'my.action', args: { value: args } } - * }] - * }, - * - * register(deps: MyCommandDeps) { - * registerCommands({ - * 'my.action': async (args) => { - * await deps.myService?.(args?.value) - * } - * }) - * }, - * - * unregister() { - * unregisterCommands(['my.action']) - * } - * } - * ``` - * - * **Example for Self-Contained Command (no external dependencies):** - * ```typescript - * // commands/calculator-command.ts - * export const calculatorCommand: SlashCommandHandler = { - * name: 'calc', - * aliases: ['calculator'], - * description: 'Simple calculator', - * - * async search(args: string) { - * if (!args.trim()) return [] - * try { - * // Safe math evaluation (implement proper parser in real use) - * const result = Function('"use strict"; return (' + args + ')')() - * return [{ - * id: 'calc-result', - * title: `${args} = ${result}`, - * description: 'Calculator result', - * type: 'command' as const, - * data: { command: 'calc.copy', args: { result: result.toString() } } - * }] - * } catch { - * return [{ - * id: 'calc-error', - * title: 'Invalid expression', - * description: 'Please enter a valid math expression', - * type: 'command' as const, - * data: { command: 'calc.noop', args: {} } - * }] - * } - * }, - * - * register() { - * registerCommands({ - * 'calc.copy': (args) => navigator.clipboard.writeText(args.result), - * 'calc.noop': () => {} // No operation - * }) - * }, - * - * unregister() { - * unregisterCommands(['calc.copy', 'calc.noop']) - * } - * } - * ``` - * - * 2. **Register Command** (in `./commands/slash.tsx`): - * ```typescript - * import { myCommand } from './my-command' - * import { calculatorCommand } from './calculator-command' // For self-contained commands - * - * export const registerSlashCommands = (deps: Record) => { - * slashCommandRegistry.register(themeCommand, { setTheme: deps.setTheme }) - * slashCommandRegistry.register(languageCommand, { setLocale: deps.setLocale }) - * slashCommandRegistry.register(myCommand, { myService: deps.myService }) // With dependencies - * slashCommandRegistry.register(calculatorCommand) // Self-contained, no dependencies - * } - * - * export const unregisterSlashCommands = () => { - * slashCommandRegistry.unregister('theme') - * slashCommandRegistry.unregister('language') - * slashCommandRegistry.unregister('mycommand') - * slashCommandRegistry.unregister('calc') // Add this line - * } - * ``` - * - * - * 3. **Update SlashCommandProvider** (in `./commands/slash.tsx`): - * ```typescript - * export const SlashCommandProvider = () => { - * const theme = useTheme() - * const myService = useMyService() // Add external dependency if needed - * - * useEffect(() => { - * registerSlashCommands({ - * setTheme: theme.setTheme, // Required for theme command - * setLocale: setLocaleOnClient, // Required for language command - * myService: myService, // Required for your custom command - * // Note: calculatorCommand doesn't need dependencies, so not listed here - * }) - * return () => unregisterSlashCommands() - * }, [theme.setTheme, myService]) // Update dependency array for all dynamic deps - * - * return null - * } - * ``` - * - * **Note:** Self-contained commands (like calculator) don't require dependencies but are - * still registered through the same system for consistent lifecycle management. - * - * 4. **Usage**: Users can now type `/mycommand` or `/mc` to use your command - * - * ## Command System Architecture - * - Commands are registered via `SlashCommandRegistry` - * - Each command is self-contained with its own dependencies - * - Commands support aliases for easier access - * - Command execution is handled by the command bus system - * - All commands should be registered through `SlashCommandProvider` for consistent lifecycle management - * - * ## Command Types - * **Commands with External Dependencies:** - * - Require external services, APIs, or React hooks - * - Must provide dependencies in `SlashCommandProvider` - * - Example: theme commands (needs useTheme), API commands (needs service) - * - * **Self-Contained Commands:** - * - Pure logic operations, no external dependencies - * - Still recommended to register through `SlashCommandProvider` for consistency - * - Example: calculator, text manipulation commands - * - * ## Available Actions - * - `@app` - Search applications - * - `@knowledge` / `@kb` - Search knowledge bases - * - `@plugin` - Search plugins - * - `@node` - Search workflow nodes (workflow pages only) - * - `/` - Execute slash commands (theme, language, banana, etc.) */ -import type { ActionItem, SearchResult } from './types' +import type { ActionItem, ScopeDescriptor, SearchResult } from './types' import { ACTION_KEYS } from '../constants' import { appAction } from './app' import { slashAction } from './commands' import { slashCommandRegistry } from './commands/registry' import { knowledgeAction } from './knowledge' import { pluginAction } from './plugin' -import { ragPipelineNodesAction } from './rag-pipeline-nodes' -import { workflowNodesAction } from './workflow-nodes' +import { scopeRegistry } from './scope-registry' -// Create dynamic Actions based on context -export const createActions = (isWorkflowPage: boolean, isRagPipelinePage: boolean) => { - const baseActions = { - slash: slashAction, - app: appAction, - knowledge: knowledgeAction, - plugin: pluginAction, - } +let defaultScopesRegistered = false - // Add appropriate node search based on context - if (isRagPipelinePage) { - return { - ...baseActions, - node: ragPipelineNodesAction, - } - } - else if (isWorkflowPage) { - return { - ...baseActions, - node: workflowNodesAction, - } - } +export const registerDefaultScopes = () => { + if (defaultScopesRegistered) + return - // Default actions without node search - return baseActions + defaultScopesRegistered = true + + scopeRegistry.register({ + id: 'slash', + shortcut: ACTION_KEYS.SLASH, + title: 'Commands', + description: 'Execute commands', + search: slashAction.search, + isAvailable: () => true, + }) + + scopeRegistry.register({ + id: 'app', + shortcut: ACTION_KEYS.APP, + title: 'Search Applications', + description: 'Search and navigate to your applications', + search: appAction.search, + isAvailable: () => true, + }) + + scopeRegistry.register({ + id: 'knowledge', + shortcut: ACTION_KEYS.KNOWLEDGE, + title: 'Search Knowledge Bases', + description: 'Search and navigate to your knowledge bases', + search: knowledgeAction.search, + isAvailable: () => true, + }) + + scopeRegistry.register({ + id: 'plugin', + shortcut: ACTION_KEYS.PLUGIN, + title: 'Search Plugins', + description: 'Search and navigate to your plugins', + search: pluginAction.search, + isAvailable: () => true, + }) } // Legacy export for backward compatibility @@ -206,26 +65,36 @@ export const Actions = { app: appAction, knowledge: knowledgeAction, plugin: pluginAction, - node: workflowNodesAction, } +const getScopeId = (scope: ScopeDescriptor | ActionItem) => ('id' in scope ? scope.id : scope.key) + +const isSlashScope = (scope: ScopeDescriptor | ActionItem) => scope.shortcut === ACTION_KEYS.SLASH + export const searchAnything = async ( locale: string, query: string, - actionItem?: ActionItem, - dynamicActions?: Record, + scope?: ScopeDescriptor | ActionItem, + scopes?: (ScopeDescriptor | ActionItem)[], ): Promise => { + registerDefaultScopes() const trimmedQuery = query.trim() - if (actionItem) { + // Backwards compatibility: if scopes is not provided or empty, use non-page-specific scopes + const effectiveScopes = (scopes && scopes.length > 0) + ? scopes + : scopeRegistry.getScopes({ isWorkflowPage: false, isRagPipelinePage: false }) + + if (scope) { const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - const prefixPattern = new RegExp(`^(${escapeRegExp(actionItem.key)}|${escapeRegExp(actionItem.shortcut)})\\s*`) + const scopeId = getScopeId(scope) + const prefixPattern = new RegExp(`^(${escapeRegExp(scope.shortcut)})\\s*`) const searchTerm = trimmedQuery.replace(prefixPattern, '').trim() try { - return await actionItem.search(query, searchTerm, locale) + return await scope.search(query, searchTerm, locale) } catch (error) { - console.warn(`Search failed for ${actionItem.key}:`, error) + console.warn(`Search failed for ${scopeId}:`, error) return [] } } @@ -233,19 +102,19 @@ export const searchAnything = async ( if (trimmedQuery.startsWith('@') || trimmedQuery.startsWith('/')) return [] - const globalSearchActions = Object.values(dynamicActions || Actions) - // Exclude slash commands from general search results - .filter(action => action.key !== ACTION_KEYS.SLASH) + // Filter out slash commands from general search + const searchScopes = effectiveScopes.filter(scope => !isSlashScope(scope)) // Use Promise.allSettled to handle partial failures gracefully - const searchPromises = globalSearchActions.map(async (action) => { + const searchPromises = searchScopes.map(async (action) => { + const actionId = getScopeId(action) try { const results = await action.search(query, query, locale) - return { success: true, data: results, actionType: action.key } + return { success: true, data: results, actionType: actionId } } catch (error) { - console.warn(`Search failed for ${action.key}:`, error) - return { success: false, data: [], actionType: action.key, error } + console.warn(`Search failed for ${actionId}:`, error) + return { success: false, data: [], actionType: actionId, error } } }) @@ -259,7 +128,7 @@ export const searchAnything = async ( allResults.push(...result.value.data) } else { - const actionKey = globalSearchActions[index]?.key || 'unknown' + const actionKey = getScopeId(searchScopes[index]) || 'unknown' failedActions.push(actionKey) } }) @@ -270,31 +139,31 @@ export const searchAnything = async ( return allResults } -export const matchAction = (query: string, actions: Record) => { - return Object.values(actions).find((action) => { - // Special handling for slash commands - if (action.key === ACTION_KEYS.SLASH) { - // Get all registered commands from the registry - const allCommands = slashCommandRegistry.getAllCommands() +// ... - // Check if query matches any registered command +export const matchAction = (query: string, scopes: ScopeDescriptor[]) => { + registerDefaultScopes() + const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + return scopes.find((scope) => { + // Special handling for slash commands + if (scope.shortcut === ACTION_KEYS.SLASH) { + const allCommands = slashCommandRegistry.getAllCommands() return allCommands.some((cmd) => { const cmdPattern = `/${cmd.name}` - - // For direct mode commands, don't match (keep in command selector) if (cmd.mode === 'direct') return false - - // For submenu mode commands, match when complete command is entered return query === cmdPattern || query.startsWith(`${cmdPattern} `) }) } - const reg = new RegExp(`^(${action.key}|${action.shortcut})(?:\\s|$)`) + // Check if query matches shortcut (exact or prefix) + // Only match if it's the full shortcut followed by space + const reg = new RegExp(`^(${escapeRegExp(scope.shortcut)})(?:\\s|$)`) return reg.test(query) }) } export * from './commands' +export * from './scope-registry' export * from './types' -export { appAction, knowledgeAction, pluginAction, workflowNodesAction } +export { appAction, knowledgeAction, pluginAction } diff --git a/web/app/components/goto-anything/actions/rag-pipeline-nodes.tsx b/web/app/components/goto-anything/actions/rag-pipeline-nodes.tsx index 430b7087cb..7afc784ffc 100644 --- a/web/app/components/goto-anything/actions/rag-pipeline-nodes.tsx +++ b/web/app/components/goto-anything/actions/rag-pipeline-nodes.tsx @@ -1,25 +1,40 @@ -import type { ActionItem } from './types' +import type { ScopeSearchHandler } from './scope-registry' +import type { SearchResult } from './types' import { ACTION_KEYS } from '../constants' +import { scopeRegistry } from './scope-registry' -// Create the RAG pipeline nodes action -export const ragPipelineNodesAction: ActionItem = { - key: ACTION_KEYS.NODE, - shortcut: ACTION_KEYS.NODE, - title: 'Search RAG Pipeline Nodes', - description: 'Find and jump to nodes in the current RAG pipeline by name or type', - searchFn: undefined, // Will be set by useRagPipelineSearch hook - search: async (_, searchTerm = '', _locale) => { +const scopeId = 'rag-pipeline-node' + +const buildSearchHandler = (searchFn?: (searchTerm: string) => SearchResult[]): ScopeSearchHandler => { + return async (_, searchTerm = '', _locale) => { try { - // Use the searchFn if available (set by useRagPipelineSearch hook) - if (ragPipelineNodesAction.searchFn) - return ragPipelineNodesAction.searchFn(searchTerm) - - // If not in RAG pipeline context, return empty array + if (searchFn) + return searchFn(searchTerm) return [] } catch (error) { console.warn('RAG pipeline nodes search failed:', error) return [] } - }, + } +} + +export const setRagPipelineNodesSearchFn = (fn: (searchTerm: string) => SearchResult[]) => { + scopeRegistry.updateSearchHandler(scopeId, buildSearchHandler(fn)) +} + +// Register the RAG pipeline nodes action +scopeRegistry.register({ + id: scopeId, + shortcut: ACTION_KEYS.NODE, + title: 'Search RAG Pipeline Nodes', + description: 'Find and jump to nodes in the current RAG pipeline by name or type', + isAvailable: context => context.isRagPipelinePage, + search: buildSearchHandler(), +}) + +// Legacy export +export const ragPipelineNodesAction = { + key: ACTION_KEYS.NODE, + search: async () => [], } diff --git a/web/app/components/goto-anything/actions/scope-registry.ts b/web/app/components/goto-anything/actions/scope-registry.ts new file mode 100644 index 0000000000..f007fadb3b --- /dev/null +++ b/web/app/components/goto-anything/actions/scope-registry.ts @@ -0,0 +1,121 @@ +import type { SearchResult } from './types' + +import { useCallback, useMemo, useSyncExternalStore } from 'react' + +export type ScopeContext = { + isWorkflowPage: boolean + isRagPipelinePage: boolean + isAdmin?: boolean +} + +export type ScopeSearchHandler = ( + query: string, + searchTerm: string, + locale?: string, +) => Promise | SearchResult[] + +export type ScopeDescriptor = { + /** + * Unique identifier for the scope (e.g. 'app', 'plugin') + */ + id: string + /** + * Shortcut to trigger this scope (e.g. '@app') + */ + shortcut: string + /** + * I18n key or string for the scope title + */ + title: string + /** + * Description for help text + */ + description: string + /** + * Search handler function + */ + search: ScopeSearchHandler + /** + * Predicate to check if this scope is available in current context + */ + isAvailable?: (context: ScopeContext) => boolean +} + +type Listener = () => void + +class ScopeRegistry { + private scopes: Map = new Map() + private listeners: Set = new Set() + private version = 0 + + register(scope: ScopeDescriptor) { + this.scopes.set(scope.id, scope) + this.notify() + } + + unregister(id: string) { + if (this.scopes.delete(id)) + this.notify() + } + + getScope(id: string) { + return this.scopes.get(id) + } + + getScopes(context: ScopeContext): ScopeDescriptor[] { + return Array.from(this.scopes.values()) + .filter(scope => !scope.isAvailable || scope.isAvailable(context)) + .sort((a, b) => a.shortcut.localeCompare(b.shortcut)) + } + + updateSearchHandler(id: string, search: ScopeSearchHandler) { + const scope = this.scopes.get(id) + if (!scope) + return + this.scopes.set(id, { ...scope, search }) + this.notify() + } + + getVersion() { + return this.version + } + + subscribe(listener: Listener) { + this.listeners.add(listener) + return () => { + this.listeners.delete(listener) + } + } + + private notify() { + this.version += 1 + this.listeners.forEach(listener => listener()) + } +} + +export const scopeRegistry = new ScopeRegistry() + +export const useScopeRegistry = (context: ScopeContext, initialize?: () => void) => { + initialize?.() + + const subscribe = useCallback( + (listener: Listener) => scopeRegistry.subscribe(listener), + [], + ) + + const getSnapshot = useCallback( + () => scopeRegistry.getVersion(), + [], + ) + + const version = useSyncExternalStore( + subscribe, + getSnapshot, + getSnapshot, + ) + + return useMemo( + () => scopeRegistry.getScopes(context), + [version, context.isWorkflowPage, context.isRagPipelinePage, context.isAdmin], + ) +} diff --git a/web/app/components/goto-anything/actions/types.ts b/web/app/components/goto-anything/actions/types.ts index 1963a808fb..56c745ade9 100644 --- a/web/app/components/goto-anything/actions/types.ts +++ b/web/app/components/goto-anything/actions/types.ts @@ -44,12 +44,19 @@ export type CommandSearchResult = { export type SearchResult = AppSearchResult | PluginSearchResult | KnowledgeSearchResult | WorkflowNodeSearchResult | CommandSearchResult +// Legacy ActionItem for backward compatibility if needed, but we should move to ScopeDescriptor export type ActionItem = { key: ActionKey shortcut: string title: string | TypeWithI18N description: string + /** + * @deprecated use search() instead + */ action?: (data: SearchResult) => void + /** + * @deprecated use search() instead + */ searchFn?: (searchTerm: string) => SearchResult[] search: ( query: string, @@ -57,3 +64,5 @@ export type ActionItem = { locale?: string, ) => (Promise | SearchResult[]) } + +export type { ScopeContext, ScopeDescriptor } from './scope-registry' diff --git a/web/app/components/goto-anything/actions/workflow-nodes.tsx b/web/app/components/goto-anything/actions/workflow-nodes.tsx index 9b743f0108..107830c2ea 100644 --- a/web/app/components/goto-anything/actions/workflow-nodes.tsx +++ b/web/app/components/goto-anything/actions/workflow-nodes.tsx @@ -1,25 +1,40 @@ -import type { ActionItem } from './types' +import type { ScopeSearchHandler } from './scope-registry' +import type { SearchResult } from './types' import { ACTION_KEYS } from '../constants' +import { scopeRegistry } from './scope-registry' -// Create the workflow nodes action -export const workflowNodesAction: ActionItem = { - key: ACTION_KEYS.NODE, - shortcut: ACTION_KEYS.NODE, - title: 'Search Workflow Nodes', - description: 'Find and jump to nodes in the current workflow by name or type', - searchFn: undefined, // Will be set by useWorkflowSearch hook - search: async (_, searchTerm = '', _locale) => { +const scopeId = 'workflow-node' + +const buildSearchHandler = (searchFn?: (searchTerm: string) => SearchResult[]): ScopeSearchHandler => { + return async (_, searchTerm = '', _locale) => { try { - // Use the searchFn if available (set by useWorkflowSearch hook) - if (workflowNodesAction.searchFn) - return workflowNodesAction.searchFn(searchTerm) - - // If not in workflow context, return empty array + if (searchFn) + return searchFn(searchTerm) return [] } catch (error) { console.warn('Workflow nodes search failed:', error) return [] } - }, + } +} + +export const setWorkflowNodesSearchFn = (fn: (searchTerm: string) => SearchResult[]) => { + scopeRegistry.updateSearchHandler(scopeId, buildSearchHandler(fn)) +} + +// Register the workflow nodes action +scopeRegistry.register({ + id: scopeId, + shortcut: ACTION_KEYS.NODE, + title: 'Search Workflow Nodes', + description: 'Find and jump to nodes in the current workflow by name or type', + isAvailable: context => context.isWorkflowPage, + search: buildSearchHandler(), +}) + +// Legacy export if needed (though we should migrate away from it) +export const workflowNodesAction = { + key: ACTION_KEYS.NODE, + search: async () => [], // Dummy implementation } diff --git a/web/app/components/goto-anything/command-selector.spec.tsx b/web/app/components/goto-anything/command-selector.spec.tsx index 0ee2086058..bf9d72b9f2 100644 --- a/web/app/components/goto-anything/command-selector.spec.tsx +++ b/web/app/components/goto-anything/command-selector.spec.tsx @@ -1,5 +1,5 @@ -import type { ActionItem } from './actions/types' -import { render, screen } from '@testing-library/react' +import type { ScopeDescriptor } from './actions/scope-registry' +import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { Command } from 'cmdk' import * as React from 'react' @@ -22,63 +22,229 @@ vi.mock('./actions/commands/registry', () => ({ }, })) -const createActions = (): Record => ({ - app: { - key: '@app', +type CommandSelectorProps = React.ComponentProps + +const mockScopes: ScopeDescriptor[] = [ + { + id: 'app', shortcut: '@app', - title: 'Apps', + title: 'Search Applications', + description: 'Search apps', search: vi.fn(), - description: '', - } as ActionItem, - plugin: { - key: '@plugin', + }, + { + id: 'knowledge', + shortcut: '@knowledge', + title: 'Search Knowledge Bases', + description: 'Search knowledge bases', + search: vi.fn(), + }, + { + id: 'plugin', shortcut: '@plugin', - title: 'Plugins', + title: 'Search Plugins', + description: 'Search plugins', search: vi.fn(), - description: '', - } as ActionItem, -}) + }, + { + id: 'workflow-node', + shortcut: '@node', + title: 'Search Nodes', + description: 'Search workflow nodes', + search: vi.fn(), + }, +] + +const mockOnCommandSelect = vi.fn() +const mockOnCommandValueChange = vi.fn() + +const buildCommandSelector = (props: Partial = {}) => ( + + + + + +) + +const renderCommandSelector = (props: Partial = {}) => { + return render(buildCommandSelector(props)) +} describe('CommandSelector', () => { - it('should list contextual search actions and notify selection', async () => { - const actions = createActions() - const onSelect = vi.fn() - - render( - - - , - ) - - const actionButton = screen.getByText('app.gotoAnything.actions.searchApplicationsDesc') - await userEvent.click(actionButton) - - expect(onSelect).toHaveBeenCalledWith('@app') + beforeEach(() => { + vi.clearAllMocks() }) - it('should render slash commands when query starts with slash', async () => { - const actions = createActions() - const onSelect = vi.fn() + describe('Basic Rendering', () => { + it('should render all scopes when no filter is provided', () => { + renderCommandSelector() - render( - - - , - ) + expect(screen.getByText('@app')).toBeInTheDocument() + expect(screen.getByText('@knowledge')).toBeInTheDocument() + expect(screen.getByText('@plugin')).toBeInTheDocument() + expect(screen.getByText('@node')).toBeInTheDocument() + }) - const slashItem = await screen.findByText('app.gotoAnything.actions.zenDesc') - await userEvent.click(slashItem) + it('should render empty filter as showing all scopes', () => { + renderCommandSelector({ searchFilter: '' }) - expect(onSelect).toHaveBeenCalledWith('/zen') + expect(screen.getByText('@app')).toBeInTheDocument() + expect(screen.getByText('@knowledge')).toBeInTheDocument() + expect(screen.getByText('@plugin')).toBeInTheDocument() + expect(screen.getByText('@node')).toBeInTheDocument() + }) + }) + + describe('Filtering Functionality', () => { + it('should filter scopes based on searchFilter - single match', () => { + renderCommandSelector({ searchFilter: 'k' }) + + expect(screen.queryByText('@app')).not.toBeInTheDocument() + expect(screen.getByText('@knowledge')).toBeInTheDocument() + expect(screen.queryByText('@plugin')).not.toBeInTheDocument() + expect(screen.queryByText('@node')).not.toBeInTheDocument() + }) + + it('should filter scopes with multiple matches', () => { + renderCommandSelector({ searchFilter: 'p' }) + + expect(screen.getByText('@app')).toBeInTheDocument() + expect(screen.queryByText('@knowledge')).not.toBeInTheDocument() + expect(screen.getByText('@plugin')).toBeInTheDocument() + expect(screen.queryByText('@node')).not.toBeInTheDocument() + }) + + it('should be case-insensitive when filtering', () => { + renderCommandSelector({ searchFilter: 'APP' }) + + expect(screen.getByText('@app')).toBeInTheDocument() + expect(screen.queryByText('@knowledge')).not.toBeInTheDocument() + }) + + it('should match partial strings', () => { + renderCommandSelector({ searchFilter: 'od' }) + + expect(screen.queryByText('@app')).not.toBeInTheDocument() + expect(screen.queryByText('@knowledge')).not.toBeInTheDocument() + expect(screen.queryByText('@plugin')).not.toBeInTheDocument() + expect(screen.getByText('@node')).toBeInTheDocument() + }) + }) + + describe('Empty State', () => { + it('should show empty state when no matches found', () => { + renderCommandSelector({ searchFilter: 'xyz' }) + + expect(screen.queryByText('@app')).not.toBeInTheDocument() + expect(screen.queryByText('@knowledge')).not.toBeInTheDocument() + expect(screen.queryByText('@plugin')).not.toBeInTheDocument() + expect(screen.queryByText('@node')).not.toBeInTheDocument() + + expect(screen.getByText('app.gotoAnything.noMatchingCommands')).toBeInTheDocument() + expect(screen.getByText('app.gotoAnything.tryDifferentSearch')).toBeInTheDocument() + }) + + it('should not show empty state when filter is empty', () => { + renderCommandSelector({ searchFilter: '' }) + + expect(screen.queryByText('app.gotoAnything.noMatchingCommands')).not.toBeInTheDocument() + }) + }) + + describe('Selection and Highlight Management', () => { + it('should call onCommandValueChange when filter changes and first item differs', async () => { + const { rerender } = renderCommandSelector({ + searchFilter: '', + commandValue: '@app', + onCommandValueChange: mockOnCommandValueChange, + }) + + rerender(buildCommandSelector({ + searchFilter: 'k', + commandValue: '@app', + onCommandValueChange: mockOnCommandValueChange, + })) + + await waitFor(() => { + expect(mockOnCommandValueChange).toHaveBeenCalledWith('@knowledge') + }) + }) + + it('should not call onCommandValueChange if current value still exists', async () => { + const { rerender } = renderCommandSelector({ + searchFilter: '', + commandValue: '@app', + onCommandValueChange: mockOnCommandValueChange, + }) + + rerender(buildCommandSelector({ + searchFilter: 'a', + commandValue: '@app', + onCommandValueChange: mockOnCommandValueChange, + })) + + await waitFor(() => { + expect(mockOnCommandValueChange).not.toHaveBeenCalled() + }) + }) + + it('should handle onCommandSelect callback correctly', async () => { + const user = userEvent.setup() + renderCommandSelector({ searchFilter: 'k' }) + + await user.click(screen.getByText('@knowledge')) + + expect(mockOnCommandSelect).toHaveBeenCalledWith('@knowledge') + }) + }) + + describe('Edge Cases', () => { + it('should handle empty scopes array', () => { + renderCommandSelector({ scopes: [] }) + + expect(screen.getByText('app.gotoAnything.noMatchingCommands')).toBeInTheDocument() + }) + + it('should handle special characters in filter', () => { + renderCommandSelector({ searchFilter: '@' }) + + expect(screen.getByText('@app')).toBeInTheDocument() + expect(screen.getByText('@knowledge')).toBeInTheDocument() + expect(screen.getByText('@plugin')).toBeInTheDocument() + expect(screen.getByText('@node')).toBeInTheDocument() + }) + + it('should handle undefined onCommandValueChange gracefully', () => { + const { rerender } = renderCommandSelector({ searchFilter: '' }) + + expect(() => { + rerender(buildCommandSelector({ searchFilter: 'k' })) + }).not.toThrow() + }) + }) + + describe('User Interactions', () => { + it('should list contextual scopes and notify selection', async () => { + const user = userEvent.setup() + renderCommandSelector({ searchFilter: 'app', originalQuery: '@app' }) + + await user.click(screen.getByText('app.gotoAnything.actions.searchApplicationsDesc')) + + expect(mockOnCommandSelect).toHaveBeenCalledWith('@app') + }) + + it('should render slash commands when query starts with slash', async () => { + const user = userEvent.setup() + renderCommandSelector({ searchFilter: 'zen', originalQuery: '/zen' }) + + const slashItem = await screen.findByText('app.gotoAnything.actions.zenDesc') + await user.click(slashItem) + + expect(mockOnCommandSelect).toHaveBeenCalledWith('/zen') + }) }) }) diff --git a/web/app/components/goto-anything/command-selector.tsx b/web/app/components/goto-anything/command-selector.tsx index fbc2339b28..411ae73528 100644 --- a/web/app/components/goto-anything/command-selector.tsx +++ b/web/app/components/goto-anything/command-selector.tsx @@ -1,5 +1,5 @@ import type { FC } from 'react' -import type { ActionItem } from './actions/types' +import type { ScopeDescriptor } from './actions/scope-registry' import { Command } from 'cmdk' import { usePathname } from 'next/navigation' import { useEffect, useMemo } from 'react' @@ -8,7 +8,7 @@ import { slashCommandRegistry } from './actions/commands/registry' import { ACTION_KEYS, SCOPE_ACTION_I18N_MAP, SLASH_COMMAND_I18N_MAP } from './constants' type Props = { - actions: Record + scopes: ScopeDescriptor[] onCommandSelect: (commandKey: string) => void searchFilter?: string commandValue?: string @@ -16,7 +16,7 @@ type Props = { originalQuery?: string } -const CommandSelector: FC = ({ actions, onCommandSelect, searchFilter, commandValue, onCommandValueChange, originalQuery }) => { +const CommandSelector: FC = ({ scopes, onCommandSelect, searchFilter, commandValue, onCommandValueChange, originalQuery }) => { const { t } = useTranslation() const pathname = usePathname() @@ -44,22 +44,29 @@ const CommandSelector: FC = ({ actions, onCommandSelect, searchFilter, co })) }, [isSlashMode, searchFilter, pathname]) - const filteredActions = useMemo(() => { + const filteredScopes = useMemo(() => { if (isSlashMode) return [] - return Object.values(actions).filter((action) => { + return scopes.filter((scope) => { // Exclude slash action when in @ mode - if (action.key === ACTION_KEYS.SLASH) + if (scope.id === 'slash' || scope.shortcut === ACTION_KEYS.SLASH) return false if (!searchFilter) return true - const filterLower = searchFilter.toLowerCase() - return action.shortcut.toLowerCase().includes(filterLower) - }) - }, [actions, searchFilter, isSlashMode]) - const allItems = isSlashMode ? slashCommands : filteredActions + // Match against shortcut or title + return scope.shortcut.toLowerCase().includes(searchFilter.toLowerCase()) + || scope.title.toLowerCase().includes(searchFilter.toLowerCase()) + }).map(scope => ({ + key: scope.shortcut, // Map to shortcut for UI display consistency + shortcut: scope.shortcut, + title: scope.title, + description: scope.description, + })) + }, [scopes, searchFilter, isSlashMode]) + + const allItems = isSlashMode ? slashCommands : filteredScopes useEffect(() => { if (allItems.length > 0 && onCommandValueChange) { diff --git a/web/app/components/goto-anything/constants.ts b/web/app/components/goto-anything/constants.ts index 39fbc601fe..c7109f0a38 100644 --- a/web/app/components/goto-anything/constants.ts +++ b/web/app/components/goto-anything/constants.ts @@ -44,3 +44,24 @@ export const SCOPE_ACTION_I18N_MAP: Record = { '@knowledge': 'app.gotoAnything.actions.searchKnowledgeBasesDesc', '@node': 'app.gotoAnything.actions.searchWorkflowNodesDesc', } as const + +/** + * Empty state i18n key mappings + */ +export const EMPTY_STATE_I18N_MAP: Record = { + app: 'app.gotoAnything.emptyState.noAppsFound', + plugin: 'app.gotoAnything.emptyState.noPluginsFound', + knowledge: 'app.gotoAnything.emptyState.noKnowledgeBasesFound', + node: 'app.gotoAnything.emptyState.noWorkflowNodesFound', +} as const + +/** + * Group heading i18n key mappings + */ +export const GROUP_HEADING_I18N_MAP: Record = { + 'app': 'app.gotoAnything.groups.apps', + 'plugin': 'app.gotoAnything.groups.plugins', + 'knowledge': 'app.gotoAnything.groups.knowledgeBases', + 'workflow-node': 'app.gotoAnything.groups.workflowNodes', + 'command': 'app.gotoAnything.groups.commands', +} as const diff --git a/web/app/components/goto-anything/index.spec.tsx b/web/app/components/goto-anything/index.spec.tsx index 7a8c1ead11..059b290593 100644 --- a/web/app/components/goto-anything/index.spec.tsx +++ b/web/app/components/goto-anything/index.spec.tsx @@ -1,4 +1,5 @@ -import type { ActionItem, SearchResult } from './actions/types' +import type { ScopeDescriptor } from './actions/scope-registry' +import type { SearchResult } from './actions/types' import { act, render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' @@ -47,34 +48,38 @@ vi.mock('./context', () => ({ GotoAnythingProvider: ({ children }: { children: React.ReactNode }) => <>{children}, })) -const createActionItem = (key: ActionItem['key'], shortcut: string): ActionItem => ({ - key, +const createScope = (id: ScopeDescriptor['id'], shortcut: string): ScopeDescriptor => ({ + id, shortcut, - title: `${key} title`, - description: `${key} desc`, - action: vi.fn(), + title: `${id} title`, + description: `${id} desc`, search: vi.fn(), }) -const actionsMock = { - slash: createActionItem('/', '/'), - app: createActionItem('@app', '@app'), - plugin: createActionItem('@plugin', '@plugin'), -} +const scopesMock = [ + createScope('slash', '/'), + createScope('app', '@app'), + createScope('plugin', '@plugin'), +] -const createActionsMock = vi.fn(() => actionsMock) -const matchActionMock = vi.fn(() => undefined) -const searchAnythingMock = vi.fn(async () => mockQueryResult.data) +type MatchAction = typeof import('./actions').matchAction +type SearchAnything = typeof import('./actions').searchAnything + +const useScopeRegistryMock = vi.fn(() => scopesMock) +const matchActionMock = vi.fn(() => undefined) +const searchAnythingMock = vi.fn(async () => mockQueryResult.data) +const registerDefaultScopesMock = vi.fn() vi.mock('./actions', () => ({ __esModule: true, - createActions: () => createActionsMock(), - matchAction: () => matchActionMock(), - searchAnything: () => searchAnythingMock(), + matchAction: (...args: Parameters) => matchActionMock(...args), + searchAnything: (...args: Parameters) => searchAnythingMock(...args), + registerDefaultScopes: () => registerDefaultScopesMock(), })) vi.mock('./actions/commands', () => ({ SlashCommandProvider: () => null, + executeCommand: vi.fn(), })) vi.mock('./actions/commands/registry', () => ({ @@ -85,6 +90,10 @@ vi.mock('./actions/commands/registry', () => ({ }, })) +vi.mock('./actions/scope-registry', () => ({ + useScopeRegistry: () => useScopeRegistryMock(), +})) + vi.mock('@/app/components/workflow/utils/common', () => ({ getKeyboardKeyCodeBySystem: () => 'ctrl', isEventTargetInputArea: () => false, diff --git a/web/app/components/goto-anything/index.tsx b/web/app/components/goto-anything/index.tsx index 8552ba4585..13dab864fc 100644 --- a/web/app/components/goto-anything/index.tsx +++ b/web/app/components/goto-anything/index.tsx @@ -17,11 +17,12 @@ import { getKeyboardKeyCodeBySystem, isEventTargetInputArea, isMac } from '@/app import { selectWorkflowNode } from '@/app/components/workflow/utils/node-navigation' import { useGetLanguage } from '@/context/i18n' import InstallFromMarketplace from '../plugins/install-plugin/install-from-marketplace' -import { createActions, matchAction, searchAnything } from './actions' -import { SlashCommandProvider } from './actions/commands' +import { matchAction, registerDefaultScopes, searchAnything } from './actions' +import { executeCommand, SlashCommandProvider } from './actions/commands' import { slashCommandRegistry } from './actions/commands/registry' +import { useScopeRegistry } from './actions/scope-registry' import CommandSelector from './command-selector' -import { ACTION_KEYS } from './constants' +import { ACTION_KEYS, EMPTY_STATE_I18N_MAP, GROUP_HEADING_I18N_MAP } from './constants' import { GotoAnythingProvider, useGotoAnythingContext } from './context' type Props = { @@ -39,11 +40,8 @@ const GotoAnything: FC = ({ const [cmdVal, setCmdVal] = useState('_') const inputRef = useRef(null) - // Filter actions based on context - const Actions = useMemo(() => { - // Create actions based on current page context - return createActions(isWorkflowPage, isRagPipelinePage) - }, [isWorkflowPage, isRagPipelinePage]) + // Fetch scopes from registry based on context + const scopes = useScopeRegistry({ isWorkflowPage, isRagPipelinePage }, registerDefaultScopes) const [activePlugin, setActivePlugin] = useState() @@ -80,8 +78,8 @@ const GotoAnything: FC = ({ }) const isCommandsMode = searchQuery.trim() === '@' || searchQuery.trim() === '/' - || (searchQuery.trim().startsWith('@') && !matchAction(searchQuery.trim(), Actions)) - || (searchQuery.trim().startsWith('/') && !matchAction(searchQuery.trim(), Actions)) + || (searchQuery.trim().startsWith('@') && !matchAction(searchQuery.trim(), scopes)) + || (searchQuery.trim().startsWith('/') && !matchAction(searchQuery.trim(), scopes)) const searchMode = useMemo(() => { if (isCommandsMode) { @@ -94,13 +92,16 @@ const GotoAnything: FC = ({ } const query = searchQueryDebouncedValue.toLowerCase() - const action = matchAction(query, Actions) + const action = matchAction(query, scopes) if (!action) return 'general' - return action.key === ACTION_KEYS.SLASH ? '@command' : action.key - }, [searchQueryDebouncedValue, Actions, isCommandsMode, searchQuery]) + if (action.id === 'slash' || action.shortcut === ACTION_KEYS.SLASH) + return '@command' + + return action.shortcut + }, [searchQueryDebouncedValue, scopes, isCommandsMode, searchQuery]) const { data: searchResults = [], isLoading, isError, error } = useQuery( { @@ -112,12 +113,12 @@ const GotoAnything: FC = ({ isWorkflowPage, isRagPipelinePage, defaultLocale, - Object.keys(Actions).sort().join(','), + scopes.map(s => s.id).sort().join(','), ], queryFn: async () => { const query = searchQueryDebouncedValue.toLowerCase() - const action = matchAction(query, Actions) - return await searchAnything(defaultLocale, query, action, Actions) + const scope = matchAction(query, scopes) + return await searchAnything(defaultLocale, query, scope, scopes) }, enabled: !!searchQueryDebouncedValue && !isCommandsMode, staleTime: 30000, @@ -167,9 +168,18 @@ const GotoAnything: FC = ({ break } - // Execute slash commands - const action = Actions.slash - action?.action?.(result) + // Execute slash commands using the command bus + // This handles both direct execution and submenu commands with args + const { command, args } = result.data + + // Try executing via command bus first (preferred for submenu commands with args) + // We can't easily check if it exists in bus without potentially running it if we were to try/catch + // but typically search results point to valid bus commands. + executeCommand(command, args) + + // Note: We previously checked slashCommandRegistry handlers here, but search results + // should return executable command strings (like 'theme.set') that are registered in the bus. + // The registry is mainly for the top-level command matching (e.g. /theme). break } case 'plugin': @@ -246,25 +256,19 @@ const GotoAnything: FC = ({
{isCommandSearch ? (() => { - const keyMap: Record = { - app: 'app.gotoAnything.emptyState.noAppsFound', - plugin: 'app.gotoAnything.emptyState.noPluginsFound', - knowledge: 'app.gotoAnything.emptyState.noKnowledgeBasesFound', - node: 'app.gotoAnything.emptyState.noWorkflowNodesFound', - } - return t((keyMap[commandType] || 'app.gotoAnything.noResults') as any) + return t((EMPTY_STATE_I18N_MAP[commandType] || 'app.gotoAnything.noResults') as any) })() : t('app.gotoAnything.noResults')}
{isCommandSearch ? t('app.gotoAnything.emptyState.tryDifferentTerm') - : t('app.gotoAnything.emptyState.trySpecificSearch', { shortcuts: Object.values(Actions).map(action => action.shortcut).join(', ') })} + : t('app.gotoAnything.emptyState.trySpecificSearch', { shortcuts: scopes.map(s => s.shortcut).join(', ') })}
) - }, [dedupedResults, searchQuery, Actions, searchMode, isLoading, isError, isCommandsMode]) + }, [dedupedResults, searchQuery, scopes, searchMode, isLoading, isError, isCommandsMode]) const defaultUI = useMemo(() => { if (searchQuery.trim()) @@ -282,7 +286,7 @@ const GotoAnything: FC = ({ ) - }, [searchQuery, Actions]) + }, [searchQuery, scopes]) useEffect(() => { if (show) { @@ -399,7 +403,7 @@ const GotoAnything: FC = ({ {isCommandsMode ? ( = ({ { - const typeMap: Record = { - 'app': 'app.gotoAnything.groups.apps', - 'plugin': 'app.gotoAnything.groups.plugins', - 'knowledge': 'app.gotoAnything.groups.knowledgeBases', - 'workflow-node': 'app.gotoAnything.groups.workflowNodes', - 'command': 'app.gotoAnything.groups.commands', - } - return t((typeMap[type] || `${type}s`) as any) + return t((GROUP_HEADING_I18N_MAP[type] || `${type}s`) as any) })()} className="p-2 capitalize text-text-secondary" > diff --git a/web/app/components/rag-pipeline/hooks/use-rag-pipeline-search.tsx b/web/app/components/rag-pipeline/hooks/use-rag-pipeline-search.tsx index b999f5ccc8..43479f3ea2 100644 --- a/web/app/components/rag-pipeline/hooks/use-rag-pipeline-search.tsx +++ b/web/app/components/rag-pipeline/hooks/use-rag-pipeline-search.tsx @@ -5,7 +5,7 @@ import type { LLMNodeType } from '@/app/components/workflow/nodes/llm/types' import type { ToolNodeType } from '@/app/components/workflow/nodes/tool/types' import type { CommonNodeType } from '@/app/components/workflow/types' import { useCallback, useEffect, useMemo } from 'react' -import { ragPipelineNodesAction } from '@/app/components/goto-anything/actions/rag-pipeline-nodes' +import { setRagPipelineNodesSearchFn } from '@/app/components/goto-anything/actions/rag-pipeline-nodes' import BlockIcon from '@/app/components/workflow/block-icon' import { useNodesInteractions } from '@/app/components/workflow/hooks/use-nodes-interactions' import { useGetToolIcon } from '@/app/components/workflow/hooks/use-tool-icon' @@ -153,16 +153,15 @@ export const useRagPipelineSearch = () => { return results }, [searchableNodes, calculateScore]) - // Directly set the search function on the action object + // Directly set the search function using the setter useEffect(() => { if (searchableNodes.length > 0) { - // Set the search function directly on the action - ragPipelineNodesAction.searchFn = searchRagPipelineNodes + setRagPipelineNodesSearchFn(searchRagPipelineNodes) } return () => { // Clean up when component unmounts - ragPipelineNodesAction.searchFn = undefined + setRagPipelineNodesSearchFn(() => []) } }, [searchableNodes, searchRagPipelineNodes]) diff --git a/web/app/components/workflow/hooks/use-workflow-search.tsx b/web/app/components/workflow/hooks/use-workflow-search.tsx index 8ca597f94e..8f7b1e59c7 100644 --- a/web/app/components/workflow/hooks/use-workflow-search.tsx +++ b/web/app/components/workflow/hooks/use-workflow-search.tsx @@ -5,7 +5,7 @@ import type { CommonNodeType } from '../types' import type { Emoji } from '@/app/components/tools/types' import { useCallback, useEffect, useMemo } from 'react' import { useNodes } from 'reactflow' -import { workflowNodesAction } from '@/app/components/goto-anything/actions/workflow-nodes' +import { setWorkflowNodesSearchFn } from '@/app/components/goto-anything/actions/workflow-nodes' import { CollectionType } from '@/app/components/tools/types' import BlockIcon from '@/app/components/workflow/block-icon' import { @@ -183,16 +183,15 @@ export const useWorkflowSearch = () => { return results }, [searchableNodes, calculateScore]) - // Directly set the search function on the action object + // Directly set the search function using the setter useEffect(() => { if (searchableNodes.length > 0) { - // Set the search function directly on the action - workflowNodesAction.searchFn = searchWorkflowNodes + setWorkflowNodesSearchFn(searchWorkflowNodes) } return () => { // Clean up when component unmounts - workflowNodesAction.searchFn = undefined + setWorkflowNodesSearchFn(() => []) } }, [searchableNodes, searchWorkflowNodes]) diff --git a/web/app/components/workflow/panel/vibe-panel/index.spec.tsx b/web/app/components/workflow/panel/vibe-panel/index.spec.tsx index f47a171f28..7b2cb4bd8f 100644 --- a/web/app/components/workflow/panel/vibe-panel/index.spec.tsx +++ b/web/app/components/workflow/panel/vibe-panel/index.spec.tsx @@ -6,7 +6,7 @@ import type { Shape as WorkflowState } from '@/app/components/workflow/store/workflow' import type { Edge, Node } from '@/app/components/workflow/types' -import { render, screen } from '@testing-library/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import Toast from '@/app/components/base/toast' import { WorkflowContext } from '@/app/components/workflow/context' @@ -224,13 +224,14 @@ describe('VibePanel', () => { // -------------------------------------------------------------------------- describe('User Interactions', () => { it('should update instruction in store when typing', async () => { - const user = userEvent.setup() const { workflowStore } = renderVibePanel() const textarea = screen.getByPlaceholderText('workflow.vibe.missingInstruction') - await user.type(textarea, 'Build a vibe flow') + fireEvent.change(textarea, { target: { value: 'Build a vibe flow' } }) - expect(workflowStore.getState().vibePanelInstruction).toBe('Build a vibe flow') + await waitFor(() => { + expect(workflowStore.getState().vibePanelInstruction).toBe('Build a vibe flow') + }) }) it('should dispatch command event with instruction when generate clicked', async () => {