feat(goto-anything): recent items, /go navigation command, deep app sub-sections (#35078)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Crazywoola 2026-04-14 11:45:58 +08:00 committed by GitHub
parent b0c4d8c541
commit 175290fa04
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 628 additions and 28 deletions

View File

@ -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 () => {

View File

@ -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()
})
})
})

View File

@ -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) => (
<div className="relative shrink-0">
<AppIcon
size="large"
iconType={app.icon_type}
icon={app.icon}
background={app.icon_background}
imageUrl={app.icon_url}
/>
<AppTypeIcon
wrapperClassName="absolute -bottom-0.5 -right-0.5 w-4 h-4 radius-xs border border-divider-regular outline-solid outline-components-panel-on-panel-item-bg"
className="h-3 w-3"
type={app.mode}
/>
</div>
)
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: (
<div className="relative shrink-0">
<AppIcon
size="large"
iconType={app.icon_type}
icon={app.icon}
background={app.icon_background}
imageUrl={app.icon_url}
/>
<AppTypeIcon
wrapperClassName="absolute -bottom-0.5 -right-0.5 w-4 h-4 radius-xs border border-divider-regular outline-solid outline-components-panel-on-panel-item-bg"
className="h-3 w-3"
type={app.mode}
/>
</div>
),
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: (
<div className="flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg">
<section.icon className="h-4 w-4 text-text-tertiary" />
</div>
),
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)

View File

@ -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<string, never>)
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<string, never>)
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<string, never>)
const handlers = vi.mocked(registerCommands).mock.calls[0][0]
await handlers['navigation.go']()
await handlers['navigation.go']({})
expect(window.location.href).toBe('/current')
})
})
})

View File

@ -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',
])
})
})

View File

@ -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: (
<div className="flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg">
<item.icon className="h-4 w-4 text-text-tertiary" />
</div>
),
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'])
},
}

View File

@ -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<string, any>) => {
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 = () => {

View File

@ -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<typeof getRecentItems>[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 {}
}

View File

@ -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<T = any> = {
id: string
@ -41,7 +41,12 @@ export type CommandSearchResult = {
type: 'command'
} & BaseSearchResult<{ command: string, args?: Record<string, any> }>
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' | '/'

View File

@ -21,6 +21,7 @@ const ResultList: FC<ResultListProps> = ({ 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' })
}

View File

@ -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<string, unknown> = {},
@ -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', () => {

View File

@ -29,12 +29,17 @@ vi.mock('@/context/i18n', () => ({
const mockMatchAction = vi.fn()
const mockSearchAnything = vi.fn()
const mockGetRecentItems = vi.fn(() => [] as Array<Record<string, unknown>>)
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') }

View File

@ -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)
}

View File

@ -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<string>()
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) => {

View File

@ -131,8 +131,10 @@ const GotoAnything: FC<Props> = ({
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

View File

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