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",