diff --git a/web/app/components/goto-anything/actions/__tests__/app.spec.ts b/web/app/components/goto-anything/actions/__tests__/app.spec.ts index 922be7675b..55939b2da8 100644 --- a/web/app/components/goto-anything/actions/__tests__/app.spec.ts +++ b/web/app/components/goto-anything/actions/__tests__/app.spec.ts @@ -41,12 +41,59 @@ describe('appAction', () => { url: 'apps', params: { page: 1, name: 'test' }, }) - expect(results).toHaveLength(1) + expect(results).toHaveLength(5) expect(results[0]).toMatchObject({ id: 'app-1', title: 'My App', type: 'app', }) + expect(results.slice(1).map(r => r.id)).toEqual([ + 'app-1:configuration', + 'app-1:overview', + 'app-1:logs', + 'app-1:develop', + ]) + }) + + it('returns workflow sub-sections for workflow-mode apps', async () => { + const { fetchAppList } = await import('@/service/apps') + vi.mocked(fetchAppList).mockResolvedValue({ + data: [ + { id: 'wf-1', name: 'Flow', description: '', mode: 'workflow', icon: '', icon_type: 'emoji', icon_background: '', icon_url: '' } as unknown as App, + ], + has_more: false, + limit: 10, + page: 1, + total: 1, + }) + + const results = await appAction.search('@app', '', 'en') + + expect(results).toHaveLength(4) + expect(results.slice(1).map(r => r.id)).toEqual([ + 'wf-1:workflow', + 'wf-1:overview', + 'wf-1:logs', + ]) + }) + + it('returns apps without sub-sections for unscoped queries', async () => { + const { fetchAppList } = await import('@/service/apps') + vi.mocked(fetchAppList).mockResolvedValue({ + data: [ + { id: 'app-1', name: 'My App', description: '', mode: 'chat', icon: '', icon_type: 'emoji', icon_background: '', icon_url: '' } as unknown as App, + { id: 'app-2', name: 'Other', description: '', mode: 'chat', icon: '', icon_type: 'emoji', icon_background: '', icon_url: '' } as unknown as App, + ], + has_more: false, + limit: 10, + page: 1, + total: 2, + }) + + const results = await appAction.search('my app', 'my app', 'en') + + expect(results).toHaveLength(2) + expect(results.map(r => r.id)).toEqual(['app-1', 'app-2']) }) it('returns empty array when response has no data', async () => { diff --git a/web/app/components/goto-anything/actions/__tests__/recent-store.spec.ts b/web/app/components/goto-anything/actions/__tests__/recent-store.spec.ts new file mode 100644 index 0000000000..d2fb346286 --- /dev/null +++ b/web/app/components/goto-anything/actions/__tests__/recent-store.spec.ts @@ -0,0 +1,78 @@ +import { addRecentItem, getRecentItems } from '../recent-store' + +describe('recent-store', () => { + beforeEach(() => { + localStorage.clear() + }) + + describe('getRecentItems', () => { + it('returns an empty array when nothing is stored', () => { + expect(getRecentItems()).toEqual([]) + }) + + it('parses stored items from localStorage', () => { + const items = [ + { id: 'app-1', title: 'App 1', path: '/app/1', originalType: 'app' as const }, + ] + localStorage.setItem('goto-anything:recent', JSON.stringify(items)) + + expect(getRecentItems()).toEqual(items) + }) + + it('returns an empty array when stored JSON is invalid', () => { + localStorage.setItem('goto-anything:recent', 'not-json') + + expect(getRecentItems()).toEqual([]) + }) + + it('returns an empty array when localStorage throws', () => { + const spy = vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('boom') + }) + + expect(getRecentItems()).toEqual([]) + spy.mockRestore() + }) + }) + + describe('addRecentItem', () => { + it('prepends a new item to the stored list', () => { + addRecentItem({ id: 'a', title: 'A', path: '/a', originalType: 'app' }) + addRecentItem({ id: 'b', title: 'B', path: '/b', originalType: 'knowledge' }) + + const stored = getRecentItems() + expect(stored.map(i => i.id)).toEqual(['b', 'a']) + }) + + it('deduplicates by id, moving the existing entry to the front', () => { + addRecentItem({ id: 'a', title: 'A', path: '/a', originalType: 'app' }) + addRecentItem({ id: 'b', title: 'B', path: '/b', originalType: 'app' }) + addRecentItem({ id: 'a', title: 'A updated', path: '/a', originalType: 'app' }) + + const stored = getRecentItems() + expect(stored.map(i => i.id)).toEqual(['a', 'b']) + expect(stored[0].title).toBe('A updated') + }) + + it('caps the list at 8 items, evicting the oldest', () => { + for (let i = 0; i < 10; i++) + addRecentItem({ id: `item-${i}`, title: `Item ${i}`, path: `/i/${i}`, originalType: 'app' }) + + const stored = getRecentItems() + expect(stored).toHaveLength(8) + expect(stored[0].id).toBe('item-9') + expect(stored[7].id).toBe('item-2') + }) + + it('silently swallows storage errors', () => { + const spy = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('quota') + }) + + expect(() => + addRecentItem({ id: 'x', title: 'X', path: '/x', originalType: 'app' }), + ).not.toThrow() + spy.mockRestore() + }) + }) +}) diff --git a/web/app/components/goto-anything/actions/app.tsx b/web/app/components/goto-anything/actions/app.tsx index 9440d14578..ce384f4019 100644 --- a/web/app/components/goto-anything/actions/app.tsx +++ b/web/app/components/goto-anything/actions/app.tsx @@ -1,10 +1,51 @@ -import type { ActionItem, AppSearchResult } from './types' +import type { ActionItem, AppSearchResult, SearchResult } from './types' import type { App } from '@/types/app' +import { RiFileListLine, RiLayoutLine, RiLineChartLine, RiNodeTree, RiTerminalBoxLine } from '@remixicon/react' +import * as React from 'react' import { fetchAppList } from '@/service/apps' +import { AppModeEnum } from '@/types/app' import { getRedirectionPath } from '@/utils/app-redirection' import { AppTypeIcon } from '../../app/type-selector' import AppIcon from '../../base/app-icon' +const WORKFLOW_MODES = new Set([AppModeEnum.WORKFLOW, AppModeEnum.ADVANCED_CHAT]) + +type AppSection = { id: string, label: string, path: string, icon: React.ElementType } + +const getAppSections = (app: App): AppSection[] => { + const base = `/app/${app.id}` + if (WORKFLOW_MODES.has(app.mode)) { + return [ + { id: 'workflow', label: 'Workflow', path: `${base}/workflow`, icon: RiNodeTree }, + { id: 'overview', label: 'Overview', path: `${base}/overview`, icon: RiLineChartLine }, + { id: 'logs', label: 'Logs', path: `${base}/logs`, icon: RiFileListLine }, + ] + } + return [ + { id: 'configuration', label: 'Configuration', path: `${base}/configuration`, icon: RiLayoutLine }, + { id: 'overview', label: 'Overview', path: `${base}/overview`, icon: RiLineChartLine }, + { id: 'logs', label: 'Logs', path: `${base}/logs`, icon: RiFileListLine }, + { id: 'develop', label: 'Develop', path: `${base}/develop`, icon: RiTerminalBoxLine }, + ] +} + +const appIcon = (app: App) => ( +
+ + +
+) + const parser = (apps: App[]): AppSearchResult[] => { return apps.map(app => ({ id: app.id, @@ -15,33 +56,50 @@ const parser = (apps: App[]): AppSearchResult[] => { id: app.id, mode: app.mode, }), - icon: ( -
- - -
- ), + icon: appIcon(app), data: app, })) } +// Generate sub-section results for matched apps when in scoped @app search +const parserWithSections = (apps: App[]): SearchResult[] => { + const results: SearchResult[] = [] + for (const app of apps) { + results.push({ + id: app.id, + title: app.name, + description: app.description, + type: 'app' as const, + path: getRedirectionPath(true, { id: app.id, mode: app.mode }), + icon: appIcon(app), + data: app, + }) + for (const section of getAppSections(app)) { + results.push({ + id: `${app.id}:${section.id}`, + title: `${app.name} / ${section.label}`, + description: section.path, + type: 'app' as const, + path: section.path, + icon: ( +
+ +
+ ), + data: app, + }) + } + } + return results +} + export const appAction: ActionItem = { key: '@app', shortcut: '@app', title: 'Search Applications', description: 'Search and navigate to your applications', - // action, - search: async (_, searchTerm = '', _locale) => { + search: async (query, searchTerm = '', _locale) => { + const isScoped = query.trimStart().startsWith('@app') || query.trimStart().startsWith('@App') try { const response = await fetchAppList({ url: 'apps', @@ -51,7 +109,7 @@ export const appAction: ActionItem = { }, }) const apps = response?.data || [] - return parser(apps) + return isScoped ? parserWithSections(apps) : parser(apps) } catch (error) { console.warn('App search failed:', error) diff --git a/web/app/components/goto-anything/actions/commands/__tests__/go.spec.tsx b/web/app/components/goto-anything/actions/commands/__tests__/go.spec.tsx new file mode 100644 index 0000000000..719eddf77b --- /dev/null +++ b/web/app/components/goto-anything/actions/commands/__tests__/go.spec.tsx @@ -0,0 +1,106 @@ +import { registerCommands, unregisterCommands } from '../command-bus' +import { goCommand } from '../go' + +vi.mock('../command-bus') + +describe('goCommand', () => { + let originalHref: string + + beforeEach(() => { + vi.clearAllMocks() + originalHref = window.location.href + }) + + afterEach(() => { + Object.defineProperty(window, 'location', { value: { href: originalHref }, writable: true }) + }) + + it('has correct metadata', () => { + expect(goCommand.name).toBe('go') + expect(goCommand.mode).toBe('submenu') + expect(goCommand.aliases).toEqual(['navigate', 'nav']) + expect(goCommand.execute).toBeUndefined() + }) + + describe('search', () => { + it('returns all navigation items when query is empty', async () => { + const results = await goCommand.search('', 'en') + + expect(results.map(r => r.id)).toEqual([ + 'go-apps', + 'go-datasets', + 'go-plugins', + 'go-tools', + 'go-explore', + 'go-account', + ]) + }) + + it('filters by id match', async () => { + const results = await goCommand.search('plugins', 'en') + + expect(results).toHaveLength(1) + expect(results[0].id).toBe('go-plugins') + }) + + it('filters by label match (case-insensitive)', async () => { + const results = await goCommand.search('Knowledge', 'en') + + expect(results).toHaveLength(1) + expect(results[0].id).toBe('go-datasets') + expect(results[0].title).toBe('Knowledge') + }) + + it('returns command results with navigation.go data', async () => { + const results = await goCommand.search('apps', 'en') + + expect(results[0]).toMatchObject({ + type: 'command', + title: 'Apps', + description: '/apps', + data: { command: 'navigation.go', args: { path: '/apps' } }, + }) + }) + + it('returns an empty list when nothing matches', async () => { + const results = await goCommand.search('no-such-section', 'en') + + expect(results).toEqual([]) + }) + }) + + describe('register / unregister', () => { + it('registers navigation.go command', () => { + goCommand.register?.({} as Record) + + expect(registerCommands).toHaveBeenCalledWith({ 'navigation.go': expect.any(Function) }) + }) + + it('unregisters navigation.go command', () => { + goCommand.unregister?.() + + expect(unregisterCommands).toHaveBeenCalledWith(['navigation.go']) + }) + + it('registered handler navigates to the provided path', async () => { + Object.defineProperty(window, 'location', { value: { href: '' }, writable: true }) + goCommand.register?.({} as Record) + const handlers = vi.mocked(registerCommands).mock.calls[0][0] + + await handlers['navigation.go']({ path: '/datasets' }) + + expect(window.location.href).toBe('/datasets') + }) + + it('registered handler does nothing when path is missing', async () => { + Object.defineProperty(window, 'location', { value: { href: '/current' }, writable: true }) + goCommand.register?.({} as Record) + const handlers = vi.mocked(registerCommands).mock.calls[0][0] + + await handlers['navigation.go']() + await handlers['navigation.go']({}) + + expect(window.location.href).toBe('/current') + }) + }) +}) diff --git a/web/app/components/goto-anything/actions/commands/__tests__/slash.spec.tsx b/web/app/components/goto-anything/actions/commands/__tests__/slash.spec.tsx index 46d1faba2e..f4825834cc 100644 --- a/web/app/components/goto-anything/actions/commands/__tests__/slash.spec.tsx +++ b/web/app/components/goto-anything/actions/commands/__tests__/slash.spec.tsx @@ -105,6 +105,7 @@ describe('SlashCommandProvider', () => { 'community', 'account', 'zen', + 'go', ]) expect(mockRegister).toHaveBeenCalledWith(expect.objectContaining({ name: 'theme' }), { setTheme: mockSetTheme }) expect(mockRegister).toHaveBeenCalledWith(expect.objectContaining({ name: 'language' }), { setLocale: mockSetLocale }) @@ -119,6 +120,7 @@ describe('SlashCommandProvider', () => { 'community', 'account', 'zen', + 'go', ]) }) }) diff --git a/web/app/components/goto-anything/actions/commands/go.tsx b/web/app/components/goto-anything/actions/commands/go.tsx new file mode 100644 index 0000000000..54de829897 --- /dev/null +++ b/web/app/components/goto-anything/actions/commands/go.tsx @@ -0,0 +1,62 @@ +import type { SlashCommandHandler } from './types' +import { + RiApps2Line, + RiBookOpenLine, + RiCompassLine, + RiPlugLine, + RiToolsLine, + RiUserLine, +} from '@remixicon/react' +import * as React from 'react' +import { registerCommands, unregisterCommands } from './command-bus' + +const NAV_ITEMS = [ + { id: 'apps', label: 'Apps', path: '/apps', icon: RiApps2Line }, + { id: 'datasets', label: 'Knowledge', path: '/datasets', icon: RiBookOpenLine }, + { id: 'plugins', label: 'Plugins', path: '/plugins', icon: RiPlugLine }, + { id: 'tools', label: 'Tools', path: '/tools', icon: RiToolsLine }, + { id: 'explore', label: 'Explore', path: '/explore', icon: RiCompassLine }, + { id: 'account', label: 'Account', path: '/account', icon: RiUserLine }, +] + +/** + * Go command - Navigate to a top-level section of the app + */ +export const goCommand: SlashCommandHandler = { + name: 'go', + aliases: ['navigate', 'nav'], + description: 'Navigate to a section', + mode: 'submenu', + + async search(args: string, _locale: string = 'en') { + const query = args.trim().toLowerCase() + const items = NAV_ITEMS.filter( + item => !query || item.id.includes(query) || item.label.toLowerCase().includes(query), + ) + return items.map(item => ({ + id: `go-${item.id}`, + title: item.label, + description: item.path, + type: 'command' as const, + icon: ( +
+ +
+ ), + data: { command: 'navigation.go', args: { path: item.path } }, + })) + }, + + register() { + registerCommands({ + 'navigation.go': async (args) => { + if (args?.path) + window.location.href = args.path + }, + }) + }, + + unregister() { + unregisterCommands(['navigation.go']) + }, +} diff --git a/web/app/components/goto-anything/actions/commands/slash.tsx b/web/app/components/goto-anything/actions/commands/slash.tsx index a5db24be41..20584eef23 100644 --- a/web/app/components/goto-anything/actions/commands/slash.tsx +++ b/web/app/components/goto-anything/actions/commands/slash.tsx @@ -9,6 +9,7 @@ import { executeCommand } from './command-bus' import { communityCommand } from './community' import { docsCommand } from './docs' import { forumCommand } from './forum' +import { goCommand } from './go' import { languageCommand } from './language' import { slashCommandRegistry } from './registry' import { themeCommand } from './theme' @@ -48,6 +49,7 @@ const registerSlashCommands = (deps: Record) => { slashCommandRegistry.register(communityCommand, {}) slashCommandRegistry.register(accountCommand, {}) slashCommandRegistry.register(zenCommand, {}) + slashCommandRegistry.register(goCommand, {}) } const unregisterSlashCommands = () => { @@ -59,6 +61,7 @@ const unregisterSlashCommands = () => { slashCommandRegistry.unregister('community') slashCommandRegistry.unregister('account') slashCommandRegistry.unregister('zen') + slashCommandRegistry.unregister('go') } export const SlashCommandProvider = () => { diff --git a/web/app/components/goto-anything/actions/recent-store.ts b/web/app/components/goto-anything/actions/recent-store.ts new file mode 100644 index 0000000000..0946818091 --- /dev/null +++ b/web/app/components/goto-anything/actions/recent-store.ts @@ -0,0 +1,30 @@ +const RECENT_ITEMS_KEY = 'goto-anything:recent' +const MAX_RECENT_ITEMS = 8 + +export function getRecentItems() { + try { + const stored = localStorage.getItem(RECENT_ITEMS_KEY) + if (!stored) + return [] + return JSON.parse(stored) as Array<{ + id: string + title: string + description?: string + path: string + originalType: 'app' | 'knowledge' + }> + } + catch { + return [] + } +} + +export function addRecentItem(item: ReturnType[number]): void { + try { + const recent = getRecentItems() + const filtered = recent.filter(r => r.id !== item.id) + const updated = [item, ...filtered].slice(0, MAX_RECENT_ITEMS) + localStorage.setItem(RECENT_ITEMS_KEY, JSON.stringify(updated)) + } + catch {} +} diff --git a/web/app/components/goto-anything/actions/types.ts b/web/app/components/goto-anything/actions/types.ts index 838195ad85..7d1ddfd4e1 100644 --- a/web/app/components/goto-anything/actions/types.ts +++ b/web/app/components/goto-anything/actions/types.ts @@ -5,7 +5,7 @@ import type { CommonNodeType } from '../../workflow/types' import type { DataSet } from '@/models/datasets' import type { App } from '@/types/app' -export type SearchResultType = 'app' | 'knowledge' | 'plugin' | 'workflow-node' | 'command' +export type SearchResultType = 'app' | 'knowledge' | 'plugin' | 'workflow-node' | 'command' | 'recent' export type BaseSearchResult = { id: string @@ -41,7 +41,12 @@ export type CommandSearchResult = { type: 'command' } & BaseSearchResult<{ command: string, args?: Record }> -export type SearchResult = AppSearchResult | PluginSearchResult | KnowledgeSearchResult | WorkflowNodeSearchResult | CommandSearchResult +export type RecentSearchResult = { + type: 'recent' + originalType: 'app' | 'knowledge' +} & BaseSearchResult<{ path: string }> + +export type SearchResult = AppSearchResult | PluginSearchResult | KnowledgeSearchResult | WorkflowNodeSearchResult | CommandSearchResult | RecentSearchResult export type ActionItem = { key: '@app' | '@knowledge' | '@plugin' | '@node' | '/' diff --git a/web/app/components/goto-anything/components/result-list.tsx b/web/app/components/goto-anything/components/result-list.tsx index 3a380dea5f..70d2c6c61e 100644 --- a/web/app/components/goto-anything/components/result-list.tsx +++ b/web/app/components/goto-anything/components/result-list.tsx @@ -21,6 +21,7 @@ const ResultList: FC = ({ groupedResults, onSelect }) => { 'knowledge': 'gotoAnything.groups.knowledgeBases', 'workflow-node': 'gotoAnything.groups.workflowNodes', 'command': 'gotoAnything.groups.commands', + 'recent': 'gotoAnything.groups.recent', } as const return t(typeMap[type as keyof typeof typeMap] || `${type}s`, { ns: 'app' }) } diff --git a/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-navigation.spec.ts b/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-navigation.spec.ts index c8a6a4a13c..b3dc034216 100644 --- a/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-navigation.spec.ts +++ b/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-navigation.spec.ts @@ -8,6 +8,7 @@ import { useGotoAnythingNavigation } from '../use-goto-anything-navigation' const mockRouterPush = vi.fn() const mockSelectWorkflowNode = vi.fn() +const mockAddRecentItem = vi.fn() type MockCommandResult = { mode: string @@ -32,6 +33,10 @@ vi.mock('../../actions/commands/registry', () => ({ }, })) +vi.mock('../../actions/recent-store', () => ({ + addRecentItem: (...args: unknown[]) => mockAddRecentItem(...args), +})) + const createMockActionItem = ( key: '@app' | '@knowledge' | '@plugin' | '@node' | '/', extra: Record = {}, @@ -314,6 +319,107 @@ describe('useGotoAnythingNavigation', () => { expect(mockRouterPush).toHaveBeenCalledWith('/datasets/kb-1') }) + + it('should record app navigation to recent history', () => { + const options = createMockOptions() + + const { result } = renderHook(() => useGotoAnythingNavigation(options)) + + act(() => { + result.current.handleNavigate({ + id: 'app-1', + type: 'app' as const, + title: 'My App', + description: 'Desc', + path: '/app/app-1', + data: { id: 'app-1', name: 'My App' } as unknown as App, + }) + }) + + expect(mockAddRecentItem).toHaveBeenCalledWith({ + id: 'app-1', + title: 'My App', + description: 'Desc', + path: '/app/app-1', + originalType: 'app', + }) + }) + + it('should record knowledge navigation to recent history', () => { + const options = createMockOptions() + + const { result } = renderHook(() => useGotoAnythingNavigation(options)) + + act(() => { + result.current.handleNavigate({ + id: 'kb-1', + type: 'knowledge' as const, + title: 'My KB', + path: '/datasets/kb-1', + data: { id: 'kb-1', name: 'My KB' } as unknown as DataSet, + }) + }) + + expect(mockAddRecentItem).toHaveBeenCalledWith( + expect.objectContaining({ id: 'kb-1', originalType: 'knowledge' }), + ) + }) + + it('should NOT record to recent history when path is missing', () => { + const options = createMockOptions() + + const { result } = renderHook(() => useGotoAnythingNavigation(options)) + + act(() => { + result.current.handleNavigate({ + id: 'app-1', + type: 'app' as const, + title: 'My App', + path: '', + data: { id: 'app-1', name: 'My App' } as unknown as App, + }) + }) + + expect(mockAddRecentItem).not.toHaveBeenCalled() + }) + + it('should navigate for recent type without recording again', () => { + const options = createMockOptions() + + const { result } = renderHook(() => useGotoAnythingNavigation(options)) + + act(() => { + result.current.handleNavigate({ + id: 'recent-app-1', + type: 'recent' as const, + originalType: 'app', + title: 'My App', + path: '/app/app-1', + data: { path: '/app/app-1' }, + }) + }) + + expect(mockRouterPush).toHaveBeenCalledWith('/app/app-1') + expect(mockAddRecentItem).not.toHaveBeenCalled() + }) + + it('should NOT call router.push for recent type when path is missing', () => { + const options = createMockOptions() + + const { result } = renderHook(() => useGotoAnythingNavigation(options)) + + act(() => { + result.current.handleNavigate({ + id: 'recent-app-1', + type: 'recent' as const, + originalType: 'app', + title: 'My App', + data: { path: '' }, + }) + }) + + expect(mockRouterPush).not.toHaveBeenCalled() + }) }) describe('setActivePlugin', () => { diff --git a/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-results.spec.ts b/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-results.spec.ts index faaf0bbd1e..b1b543d35a 100644 --- a/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-results.spec.ts +++ b/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-results.spec.ts @@ -29,12 +29,17 @@ vi.mock('@/context/i18n', () => ({ const mockMatchAction = vi.fn() const mockSearchAnything = vi.fn() +const mockGetRecentItems = vi.fn(() => [] as Array>) vi.mock('../../actions', () => ({ matchAction: (...args: unknown[]) => mockMatchAction(...args), searchAnything: (...args: unknown[]) => mockSearchAnything(...args), })) +vi.mock('../../actions/recent-store', () => ({ + getRecentItems: () => mockGetRecentItems(), +})) + const createMockActionItem = (key: '@app' | '@knowledge' | '@plugin' | '@node' | '/') => ({ key, shortcut: key, @@ -61,6 +66,7 @@ describe('useGotoAnythingResults', () => { capturedQueryFn = null mockMatchAction.mockReset() mockSearchAnything.mockReset() + mockGetRecentItems.mockReturnValue([]) }) describe('initialization', () => { @@ -297,6 +303,59 @@ describe('useGotoAnythingResults', () => { }) }) + describe('recent results', () => { + it('surfaces recent items when the search query is empty', () => { + mockGetRecentItems.mockReturnValue([ + { id: 'app-1', title: 'My App', description: 'Desc', path: '/app/app-1', originalType: 'app' }, + { id: 'kb-1', title: 'My KB', path: '/datasets/kb-1', originalType: 'knowledge' }, + ]) + + const { result } = renderHook(() => useGotoAnythingResults(createMockOptions({ + searchQueryDebouncedValue: '', + }))) + + expect(result.current.dedupedResults).toHaveLength(2) + expect(result.current.dedupedResults[0]).toMatchObject({ + id: 'recent-app-1', + type: 'recent', + originalType: 'app', + path: '/app/app-1', + data: { path: '/app/app-1' }, + }) + expect(result.current.groupedResults.recent).toHaveLength(2) + }) + + it('does not surface recent items when a query is active', () => { + mockGetRecentItems.mockReturnValue([ + { id: 'app-1', title: 'My App', path: '/app/app-1', originalType: 'app' }, + ]) + mockQueryResult = { + data: [{ id: 's1', type: 'app', title: 'Searched' }], + isLoading: false, + isError: false, + error: null, + } + + const { result } = renderHook(() => useGotoAnythingResults(createMockOptions({ + searchQueryDebouncedValue: 'foo', + }))) + + expect(result.current.dedupedResults.map(r => r.id)).toEqual(['s1']) + }) + + it('does not surface recent items in commands mode', () => { + mockGetRecentItems.mockReturnValue([ + { id: 'app-1', title: 'My App', path: '/app/app-1', originalType: 'app' }, + ]) + + const { result } = renderHook(() => useGotoAnythingResults(createMockOptions({ + isCommandsMode: true, + }))) + + expect(result.current.dedupedResults).toEqual([]) + }) + }) + describe('queryFn execution', () => { it('should call matchAction with lowercased query', async () => { const mockActions = { app: createMockActionItem('@app') } diff --git a/web/app/components/goto-anything/hooks/use-goto-anything-navigation.ts b/web/app/components/goto-anything/hooks/use-goto-anything-navigation.ts index 1c75adbf72..a3be296c8c 100644 --- a/web/app/components/goto-anything/hooks/use-goto-anything-navigation.ts +++ b/web/app/components/goto-anything/hooks/use-goto-anything-navigation.ts @@ -7,6 +7,7 @@ import { useCallback, useState } from 'react' import { selectWorkflowNode } from '@/app/components/workflow/utils/node-navigation' import { useRouter } from '@/next/navigation' import { slashCommandRegistry } from '../actions/commands/registry' +import { addRecentItem } from '../actions/recent-store' type UseGotoAnythingNavigationReturn = { handleCommandSelect: (commandKey: string) => void @@ -80,8 +81,23 @@ export const useGotoAnythingNavigation = ( if (result.metadata?.nodeId) selectWorkflowNode(result.metadata.nodeId, true) + break + case 'recent': + if (result.path) + router.push(result.path) + break default: + // Record to recent history for app and knowledge results + if ((result.type === 'app' || result.type === 'knowledge') && result.path) { + addRecentItem({ + id: result.id, + title: result.title, + description: result.description, + path: result.path, + originalType: result.type, + }) + } if (result.path) router.push(result.path) } diff --git a/web/app/components/goto-anything/hooks/use-goto-anything-results.ts b/web/app/components/goto-anything/hooks/use-goto-anything-results.ts index 8fac699fdc..36e8397b6f 100644 --- a/web/app/components/goto-anything/hooks/use-goto-anything-results.ts +++ b/web/app/components/goto-anything/hooks/use-goto-anything-results.ts @@ -1,10 +1,13 @@ 'use client' -import type { ActionItem, SearchResult } from '../actions/types' +import type { ActionItem, RecentSearchResult, SearchResult } from '../actions/types' +import { RiTimeLine } from '@remixicon/react' import { useQuery } from '@tanstack/react-query' +import * as React from 'react' import { useEffect, useMemo } from 'react' import { useGetLanguage } from '@/context/i18n' import { matchAction, searchAnything } from '../actions' +import { getRecentItems } from '../actions/recent-store' type UseGotoAnythingResultsReturn = { searchResults: SearchResult[] @@ -70,16 +73,37 @@ export const useGotoAnythingResults = ( }, ) + // Build recent items to show when search is empty + const recentResults = useMemo((): RecentSearchResult[] => { + if (searchQueryDebouncedValue || isCommandsMode) + return [] + return getRecentItems().map(item => ({ + id: `recent-${item.id}`, + title: item.title, + description: item.description, + type: 'recent' as const, + originalType: item.originalType, + path: item.path, + icon: React.createElement( + 'div', + { className: 'flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg' }, + React.createElement(RiTimeLine, { className: 'h-4 w-4 text-text-tertiary' }), + ), + data: { path: item.path }, + })) + }, [searchQueryDebouncedValue, isCommandsMode]) + const dedupedResults = useMemo(() => { + const allResults = recentResults.length ? recentResults : searchResults const seen = new Set() - return searchResults.filter((result) => { + return allResults.filter((result) => { const key = `${result.type}-${result.id}` if (seen.has(key)) return false seen.add(key) return true }) - }, [searchResults]) + }, [searchResults, recentResults]) // Group results by type const groupedResults = useMemo(() => dedupedResults.reduce((acc, result) => { diff --git a/web/app/components/goto-anything/index.tsx b/web/app/components/goto-anything/index.tsx index d0b2502408..27ac077c5a 100644 --- a/web/app/components/goto-anything/index.tsx +++ b/web/app/components/goto-anything/index.tsx @@ -131,8 +131,10 @@ const GotoAnything: FC = ({ return 'loading' if (isError) return 'error' - if (!searchQuery.trim()) - return 'default' + if (!searchQuery.trim()) { + // Show default hint only when there are no recent items to display + return dedupedResults.length === 0 ? 'default' : null + } if (dedupedResults.length === 0 && !isCommandsMode) return 'no-results' return null diff --git a/web/i18n/en-US/app.json b/web/i18n/en-US/app.json index 0c3b35ba14..0ad608d53c 100644 --- a/web/i18n/en-US/app.json +++ b/web/i18n/en-US/app.json @@ -91,6 +91,7 @@ "gotoAnything.groups.commands": "Commands", "gotoAnything.groups.knowledgeBases": "Knowledge Bases", "gotoAnything.groups.plugins": "Plugins", + "gotoAnything.groups.recent": "Recent", "gotoAnything.groups.workflowNodes": "Workflow Nodes", "gotoAnything.inScope": "in {{scope}}s", "gotoAnything.noMatchingCommands": "No matching commands found",