diff --git a/web/__tests__/goto-anything/command-selector.test.tsx b/web/__tests__/goto-anything/command-selector.test.tsx
new file mode 100644
index 0000000000..1073b9d481
--- /dev/null
+++ b/web/__tests__/goto-anything/command-selector.test.tsx
@@ -0,0 +1,333 @@
+import React from 'react'
+import { fireEvent, render, screen } from '@testing-library/react'
+import '@testing-library/jest-dom'
+import CommandSelector from '../../app/components/goto-anything/command-selector'
+import type { ActionItem } from '../../app/components/goto-anything/actions/types'
+
+jest.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}))
+
+jest.mock('cmdk', () => ({
+ Command: {
+ Group: ({ children, className }: any) =>
{children}
,
+ Item: ({ children, onSelect, value, className }: any) => (
+ onSelect && onSelect()}
+ data-value={value}
+ data-testid={`command-item-${value}`}
+ >
+ {children}
+
+ ),
+ },
+}))
+
+describe('CommandSelector', () => {
+ const mockActions: Record = {
+ app: {
+ key: '@app',
+ shortcut: '@app',
+ title: 'Search Applications',
+ description: 'Search apps',
+ search: jest.fn(),
+ },
+ knowledge: {
+ key: '@knowledge',
+ shortcut: '@knowledge',
+ title: 'Search Knowledge',
+ description: 'Search knowledge bases',
+ search: jest.fn(),
+ },
+ plugin: {
+ key: '@plugin',
+ shortcut: '@plugin',
+ title: 'Search Plugins',
+ description: 'Search plugins',
+ search: jest.fn(),
+ },
+ node: {
+ key: '@node',
+ shortcut: '@node',
+ title: 'Search Nodes',
+ description: 'Search workflow nodes',
+ search: jest.fn(),
+ },
+ }
+
+ const mockOnCommandSelect = jest.fn()
+ const mockOnCommandValueChange = jest.fn()
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ describe('Basic Rendering', () => {
+ it('should render all actions when no filter is provided', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByTestId('command-item-@app')).toBeInTheDocument()
+ expect(screen.getByTestId('command-item-@knowledge')).toBeInTheDocument()
+ expect(screen.getByTestId('command-item-@plugin')).toBeInTheDocument()
+ expect(screen.getByTestId('command-item-@node')).toBeInTheDocument()
+ })
+
+ it('should render empty filter as showing all actions', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByTestId('command-item-@app')).toBeInTheDocument()
+ expect(screen.getByTestId('command-item-@knowledge')).toBeInTheDocument()
+ expect(screen.getByTestId('command-item-@plugin')).toBeInTheDocument()
+ expect(screen.getByTestId('command-item-@node')).toBeInTheDocument()
+ })
+ })
+
+ describe('Filtering Functionality', () => {
+ it('should filter actions based on searchFilter - single match', () => {
+ render(
+ ,
+ )
+
+ expect(screen.queryByTestId('command-item-@app')).not.toBeInTheDocument()
+ expect(screen.getByTestId('command-item-@knowledge')).toBeInTheDocument()
+ expect(screen.queryByTestId('command-item-@plugin')).not.toBeInTheDocument()
+ expect(screen.queryByTestId('command-item-@node')).not.toBeInTheDocument()
+ })
+
+ it('should filter actions with multiple matches', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByTestId('command-item-@app')).toBeInTheDocument()
+ expect(screen.queryByTestId('command-item-@knowledge')).not.toBeInTheDocument()
+ expect(screen.getByTestId('command-item-@plugin')).toBeInTheDocument()
+ expect(screen.queryByTestId('command-item-@node')).not.toBeInTheDocument()
+ })
+
+ it('should be case-insensitive when filtering', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByTestId('command-item-@app')).toBeInTheDocument()
+ expect(screen.queryByTestId('command-item-@knowledge')).not.toBeInTheDocument()
+ })
+
+ it('should match partial strings', () => {
+ render(
+ ,
+ )
+
+ expect(screen.queryByTestId('command-item-@app')).not.toBeInTheDocument()
+ expect(screen.getByTestId('command-item-@knowledge')).toBeInTheDocument()
+ expect(screen.queryByTestId('command-item-@plugin')).not.toBeInTheDocument()
+ expect(screen.queryByTestId('command-item-@node')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('Empty State', () => {
+ it('should show empty state when no matches found', () => {
+ render(
+ ,
+ )
+
+ expect(screen.queryByTestId('command-item-@app')).not.toBeInTheDocument()
+ expect(screen.queryByTestId('command-item-@knowledge')).not.toBeInTheDocument()
+ expect(screen.queryByTestId('command-item-@plugin')).not.toBeInTheDocument()
+ expect(screen.queryByTestId('command-item-@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', () => {
+ render(
+ ,
+ )
+
+ expect(screen.queryByText('app.gotoAnything.noMatchingCommands')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('Selection and Highlight Management', () => {
+ it('should call onCommandValueChange when filter changes and first item differs', () => {
+ const { rerender } = render(
+ ,
+ )
+
+ rerender(
+ ,
+ )
+
+ expect(mockOnCommandValueChange).toHaveBeenCalledWith('@knowledge')
+ })
+
+ it('should not call onCommandValueChange if current value still exists', () => {
+ const { rerender } = render(
+ ,
+ )
+
+ rerender(
+ ,
+ )
+
+ expect(mockOnCommandValueChange).not.toHaveBeenCalled()
+ })
+
+ it('should handle onCommandSelect callback correctly', () => {
+ render(
+ ,
+ )
+
+ const knowledgeItem = screen.getByTestId('command-item-@knowledge')
+ fireEvent.click(knowledgeItem)
+
+ expect(mockOnCommandSelect).toHaveBeenCalledWith('@knowledge')
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle empty actions object', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('app.gotoAnything.noMatchingCommands')).toBeInTheDocument()
+ })
+
+ it('should handle special characters in filter', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByTestId('command-item-@app')).toBeInTheDocument()
+ expect(screen.getByTestId('command-item-@knowledge')).toBeInTheDocument()
+ expect(screen.getByTestId('command-item-@plugin')).toBeInTheDocument()
+ expect(screen.getByTestId('command-item-@node')).toBeInTheDocument()
+ })
+
+ it('should handle undefined onCommandValueChange gracefully', () => {
+ const { rerender } = render(
+ ,
+ )
+
+ expect(() => {
+ rerender(
+ ,
+ )
+ }).not.toThrow()
+ })
+ })
+
+ describe('Backward Compatibility', () => {
+ it('should work without searchFilter prop (backward compatible)', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByTestId('command-item-@app')).toBeInTheDocument()
+ expect(screen.getByTestId('command-item-@knowledge')).toBeInTheDocument()
+ expect(screen.getByTestId('command-item-@plugin')).toBeInTheDocument()
+ expect(screen.getByTestId('command-item-@node')).toBeInTheDocument()
+ })
+
+ it('should work without commandValue and onCommandValueChange props', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByTestId('command-item-@knowledge')).toBeInTheDocument()
+ expect(screen.queryByTestId('command-item-@app')).not.toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/goto-anything/command-selector.tsx b/web/app/components/goto-anything/command-selector.tsx
index 169f377c31..d66a22eab7 100644
--- a/web/app/components/goto-anything/command-selector.tsx
+++ b/web/app/components/goto-anything/command-selector.tsx
@@ -1,4 +1,5 @@
import type { FC } from 'react'
+import { useEffect } from 'react'
import { Command } from 'cmdk'
import { useTranslation } from 'react-i18next'
import type { ActionItem } from './actions/types'
@@ -6,18 +7,54 @@ import type { ActionItem } from './actions/types'
type Props = {
actions: Record
onCommandSelect: (commandKey: string) => void
+ searchFilter?: string
+ commandValue?: string
+ onCommandValueChange?: (value: string) => void
}
-const CommandSelector: FC = ({ actions, onCommandSelect }) => {
+const CommandSelector: FC = ({ actions, onCommandSelect, searchFilter, commandValue, onCommandValueChange }) => {
const { t } = useTranslation()
+ const filteredActions = Object.values(actions).filter((action) => {
+ if (!searchFilter)
+ return true
+ const filterLower = searchFilter.toLowerCase()
+ return action.shortcut.toLowerCase().includes(filterLower)
+ || action.key.toLowerCase().includes(filterLower)
+ })
+
+ useEffect(() => {
+ if (filteredActions.length > 0 && onCommandValueChange) {
+ const currentValueExists = filteredActions.some(action => action.shortcut === commandValue)
+ if (!currentValueExists)
+ onCommandValueChange(filteredActions[0].shortcut)
+ }
+ }, [searchFilter, filteredActions.length])
+
+ if (filteredActions.length === 0) {
+ return (
+
+
+
+
+ {t('app.gotoAnything.noMatchingCommands')}
+
+
+ {t('app.gotoAnything.tryDifferentSearch')}
+
+
+
+
+ )
+ }
+
return (
{t('app.gotoAnything.selectSearchType')}
- {Object.values(actions).map(action => (
+ {filteredActions.map(action => (
= ({
wait: 300,
})
- const isCommandsMode = searchQuery.trim() === '@'
+ const isCommandsMode = searchQuery.trim() === '@' || (searchQuery.trim().startsWith('@') && !matchAction(searchQuery.trim(), Actions))
const searchMode = useMemo(() => {
if (isCommandsMode) return 'commands'
@@ -253,8 +253,9 @@ const GotoAnything: FC = ({
value={searchQuery}
placeholder={t('app.gotoAnything.searchPlaceholder')}
onChange={(e) => {
- setCmdVal('')
setSearchQuery(e.target.value)
+ if (!e.target.value.startsWith('@'))
+ setCmdVal('')
}}
className='flex-1 !border-0 !bg-transparent !shadow-none'
wrapperClassName='flex-1 !border-0 !bg-transparent'
@@ -301,6 +302,9 @@ const GotoAnything: FC = ({
) : (
Object.entries(groupedResults).map(([type, results], groupIndex) => (