refactor(goto-anything): simplify command context and execution (#43136)

This commit is contained in:
yyh authored and GitHub committed 2026-09-29 06:37:41 +00:00
1 parent 5c4489911c
commit d42276a5c0
62 files changed
+745 -2288

No files matched your search

-5
View File
@@ -1940,11 +1940,6 @@
"count": 2
}
},
"web/app/components/goto-anything/actions/commands/command-bus.ts": {
"typescript/no-explicit-any": {
"count": 2
}
},
"web/app/components/header/account-setting/data-source-page-new/hooks/index.ts": {
"no-barrel-files/no-barrel-files": {
"count": 2
+3 -1
View File
@@ -20,4 +20,6 @@ Search actions adapt application, knowledge, plugin, workflow, and RAG owners in
- Ordinary text searches localized command names, existing aliases, submenu choices, and resources. Commands stay usable while remote searches load or fail.
- Grid navigation follows the displayed rows; ordinary results use a listbox. Pointer and keyboard use the same highlighted state, while DOM focus stays in the input.
- Selecting a submenu or scope keeps the palette open and the input editable. Clearing the input returns to the home view. Escape dismisses the dialog.
- The registry publishes command registration changes so the first open can show commands without requiring a keystroke. Command handlers retain execution and availability ownership.
- A fixed command catalog is available on the first render. Its lookup registry only indexes definitions; it has no registration effects, subscriptions, or separate execution bus.
- React owns the current language, workspace availability, and Studio app context. Search and execution receive this context explicitly; command definitions never retain user or request state. Execution rechecks availability.
- Browser effects (navigation, external windows, theme/language changes, generator opening) run only when a result is activated. The palette remains lazily loaded on the client; importing/searching the catalog is safe during server rendering.
@@ -6,7 +6,9 @@ import { detectPlatform } from '@tanstack/react-hotkeys'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { toast } from '@/app/notifications'
import { createConsoleQueryWrapper } from '@/test/console/query-data'
import { goCommand } from '../actions/commands/go'
import { gotoAnythingDialogHandle } from '../dialog-handle'
import { GotoAnything } from '../index'
@@ -203,10 +205,11 @@ const actionsMock = {
const createActionsMock = vi.fn(
(
_slash: ActionItem,
_isWorkflowPage?: boolean,
_isRagPipelinePage?: boolean,
_availability?: { agents: boolean; skills: boolean },
) => actionsMock,
): Record<string, ActionItem> => actionsMock,
)
const matchActionMock = vi.fn<
(query: string, actions: Record<string, ActionItem>) => ActionItem | undefined
@@ -218,10 +221,6 @@ vi.mock('../actions', () => ({
matchActionMock(query, actions),
}))
vi.mock('../actions/commands/slash-provider', () => ({
SlashCommandProvider: () => null,
}))
type MockSlashCommand = {
mode: SlashCommand['mode']
execute?: () => void
@@ -230,18 +229,18 @@ type MockSlashCommand = {
let mockFindCommand: MockSlashCommand = null
let mockAvailableCommands: SlashCommand[] = []
vi.mock('../actions/commands/registry', async (importOriginal) => {
const actual = await importOriginal<typeof import('../actions/commands/registry')>()
const registry = new actual.SlashCommandRegistry()
registry.getSnapshot = () => mockAvailableCommands
registry.getAllCommands = () => mockAvailableCommands
vi.mock('../actions/commands/catalog', async () => {
const { SlashCommandRegistry } = await import('../actions/commands/registry')
const registry = new SlashCommandRegistry([])
registry.getAvailableCommands = (context) =>
mockAvailableCommands.filter((command) => command.isAvailable?.(context) ?? true)
registry.findCommand = (name) =>
mockFindCommand
? { name, description: '', search: () => [], ...mockFindCommand }
? { name, description: '', search: () => [], execute: vi.fn(), ...mockFindCommand }
: mockAvailableCommands.find(
(command) => command.name === name || command.aliases?.includes(name),
)
return { ...actual, slashCommandRegistry: registry }
return { slashCommandRegistry: registry }
})
vi.mock('@/app/components/workflow/utils/node-navigation', () => ({
@@ -289,6 +288,7 @@ describe('GotoAnything', () => {
enabledRemoteSearches = []
previousRemoteData = {}
matchActionMock.mockReset()
createActionsMock.mockImplementation(() => actionsMock)
visibilityState.agentEnabled = true
visibilityState.datasetOperator = false
visibilityState.enableSkill = true
@@ -488,6 +488,7 @@ describe('GotoAnything', () => {
{
name: 'theme',
description: 'Theme',
execute: vi.fn(),
search: () => {
throw new Error('Unavailable')
},
@@ -507,8 +508,20 @@ describe('GotoAnything', () => {
it('shows available actions before search scopes without requiring a prefix', async () => {
mockAvailableCommands = [
{ name: 'models', description: 'Models', mode: 'direct', search: () => [] },
{ name: 'refine', description: 'Refine', isAvailable: () => false, search: () => [] },
{
name: 'models',
description: 'Models',
mode: 'direct',
execute: vi.fn(),
search: () => [],
},
{
name: 'refine',
description: 'Refine',
isAvailable: () => false,
execute: vi.fn(),
search: () => [],
},
]
renderGotoAnything(<GotoAnything />)
triggerSearchShortcut()
@@ -523,7 +536,9 @@ describe('GotoAnything', () => {
it('opens a command submenu and returns home by clearing the input', async () => {
const user = userEvent.setup()
mockAvailableCommands = [{ name: 'theme', description: 'Theme', search: () => [] }]
mockAvailableCommands = [
{ name: 'theme', description: 'Theme', execute: vi.fn(), search: () => [] },
]
matchActionMock.mockImplementation((query) =>
query.startsWith('/theme ') ? actionsMock.slash : undefined,
)
@@ -575,7 +590,13 @@ describe('GotoAnything', () => {
data: { command: 'theme.set', args: { value: 'dark' } },
}
mockAvailableCommands = [
{ name: 'theme', description: 'Theme', mode: 'submenu', search: () => [result] },
{
name: 'theme',
description: 'Theme',
mode: 'submenu',
execute: vi.fn(),
search: () => [result],
},
]
renderGotoAnything(<GotoAnything />)
triggerSearchShortcut()
@@ -612,6 +633,56 @@ describe('GotoAnything', () => {
})
})
it('shows feedback when an activated command rejects and allows reopening', async () => {
const user = userEvent.setup()
const failure = new Error('Execution failed')
const toastError = vi.spyOn(toast, 'error').mockReturnValue('command-error')
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
mockAvailableCommands = [
{
name: 'models',
description: 'Models',
mode: 'direct',
search: () => [],
execute: () => Promise.reject(failure),
},
]
renderGotoAnything(<GotoAnything />)
triggerSearchShortcut()
await user.click(await screen.findByRole('gridcell', { name: /\/models/ }))
await waitFor(() =>
expect(toastError).toHaveBeenCalledExactlyOnceWith('common.api.actionFailed'),
)
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
triggerSearchShortcut()
expect(await screen.findByRole('combobox')).toHaveValue('')
toastError.mockRestore()
consoleError.mockRestore()
})
it('updates an open command submenu when workspace access changes', async () => {
const user = userEvent.setup()
mockAvailableCommands = [goCommand]
createActionsMock.mockImplementation((slash) => ({ ...actionsMock, slash }))
matchActionMock.mockImplementation((query, actions) =>
actions.slash?.matches?.(query) ? actions.slash : undefined,
)
const { rerender } = renderGotoAnything(<GotoAnything />)
triggerSearchShortcut()
const input = await screen.findByRole('combobox')
await user.type(input, '/go ')
expect(screen.getByRole('option', { name: 'Agents /agents' })).toBeInTheDocument()
expect(screen.getByRole('option', { name: 'Skills /skills' })).toBeInTheDocument()
visibilityState.agentEnabled = false
visibilityState.datasetOperator = true
rerender(<GotoAnything />)
expect(screen.queryByRole('option', { name: 'Agents /agents' })).not.toBeInTheDocument()
expect(screen.queryByRole('option', { name: 'Skills /skills' })).not.toBeInTheDocument()
await user.click(screen.getByRole('option', { name: 'Apps /apps' }))
expect(routerPush).toHaveBeenCalledExactlyOnceWith('/apps')
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
})
describe('search functionality', () => {
it.each([
[{ agentEnabled: true, datasetOperator: false, enableSkill: true }, true, true],
@@ -625,7 +696,10 @@ describe('GotoAnything', () => {
renderGotoAnything(<GotoAnything />)
expect(createActionsMock).toHaveBeenCalledWith(false, false, { agents, skills })
expect(createActionsMock).toHaveBeenCalledWith(expect.any(Object), false, false, {
agents,
skills,
})
},
)
@@ -690,8 +764,8 @@ describe('GotoAnything', () => {
it('should navigate and loop within a command grid row with ArrowRight', async () => {
const user = userEvent.setup()
mockAvailableCommands = [
{ search: () => [], name: 'theme', description: 'Change theme' },
{ search: () => [], name: 'language', description: 'Change language' },
{ execute: vi.fn(), search: () => [], name: 'theme', description: 'Change theme' },
{ execute: vi.fn(), search: () => [], name: 'language', description: 'Change language' },
]
renderGotoAnything(<GotoAnything />)
@@ -716,8 +790,8 @@ describe('GotoAnything', () => {
it('should announce the displayed command count', async () => {
const user = userEvent.setup()
mockAvailableCommands = [
{ search: () => [], name: 'theme', description: 'Change theme' },
{ search: () => [], name: 'language', description: 'Change language' },
{ execute: vi.fn(), search: () => [], name: 'theme', description: 'Change theme' },
{ execute: vi.fn(), search: () => [], name: 'language', description: 'Change language' },
]
renderGotoAnything(<GotoAnything />)
@@ -770,7 +844,7 @@ describe('GotoAnything', () => {
it('shows the localized system model name for /models', async () => {
const user = userEvent.setup()
mockAvailableCommands = [
{ search: () => [], name: 'models', description: 'Fallback description' },
{ execute: vi.fn(), search: () => [], name: 'models', description: 'Fallback description' },
]
renderGotoAnything(<GotoAnything />)
triggerSearchShortcut()
@@ -788,7 +862,9 @@ describe('GotoAnything', () => {
it('keeps an exact submenu command in the grid until selection commits it', async () => {
const user = userEvent.setup()
mockAvailableCommands = [{ search: () => [], name: 'theme', description: 'Change theme' }]
mockAvailableCommands = [
{ execute: vi.fn(), search: () => [], name: 'theme', description: 'Change theme' },
]
matchActionMock.mockImplementation((query: string) =>
query.startsWith('/theme ') ? actionsMock.slash : undefined,
)
@@ -822,7 +898,9 @@ describe('GotoAnything', () => {
it('keeps a submenu root result visible while the committed delimiter catches up', async () => {
const user = userEvent.setup()
mockAvailableCommands = [{ search: () => [], name: 'theme', description: 'Change theme' }]
mockAvailableCommands = [
{ execute: vi.fn(), search: () => [], name: 'theme', description: 'Change theme' },
]
matchActionMock.mockImplementation((query: string) =>
query.startsWith('/theme ') ? actionsMock.slash : undefined,
)
@@ -858,7 +936,9 @@ describe('GotoAnything', () => {
it('does not leak a pending remote search into command or local-result contexts', async () => {
const user = userEvent.setup()
mockAvailableCommands = [{ search: () => [], name: 'theme', description: 'Change theme' }]
mockAvailableCommands = [
{ execute: vi.fn(), search: () => [], name: 'theme', description: 'Change theme' },
]
matchActionMock.mockImplementation((query: string) =>
query.startsWith('/theme ') ? actionsMock.slash : undefined,
)
@@ -1429,7 +1509,9 @@ describe('GotoAnything', () => {
execute: executeMock,
isAvailable: () => true,
}
mockAvailableCommands = [{ search: () => [], name: 'theme', description: 'Change theme' }]
mockAvailableCommands = [
{ execute: vi.fn(), search: () => [], name: 'theme', description: 'Change theme' },
]
renderGotoAnything(<GotoAnything />)
triggerSearchShortcut()
@@ -1480,7 +1562,7 @@ describe('GotoAnything', () => {
execute: executeMock,
}
mockAvailableCommands = [
{ search: () => [], name: 'language', description: 'Change language' },
{ execute: vi.fn(), search: () => [], name: 'language', description: 'Change language' },
]
renderGotoAnything(<GotoAnything />)
@@ -1506,7 +1588,9 @@ describe('GotoAnything', () => {
execute: vi.fn(),
isAvailable: () => true,
}
mockAvailableCommands = [{ search: () => [], name: 'theme', description: 'Change theme' }]
mockAvailableCommands = [
{ execute: vi.fn(), search: () => [], name: 'theme', description: 'Change theme' },
]
renderGotoAnything(<GotoAnything />)
triggerSearchShortcut()
@@ -1,7 +1,10 @@
import type { ActionItem } from '../types'
import { slashCommandRegistry } from '../commands/registry'
import { createCommandContext } from '../commands/__tests__/context'
import { createSlashAction } from '../commands/slash'
import { createActions, getActionSearchTerm, matchAction } from '../index'
const slash = createSlashAction(createCommandContext())
vi.mock('../app', () => ({
appAction: {
key: '@app',
@@ -52,17 +55,6 @@ vi.mock('../agent', () => ({
} satisfies ActionItem,
}))
vi.mock('../commands/slash', () => ({
slashAction: {
key: '/',
shortcut: '/',
title: 'Commands',
description: 'Slash commands',
source: 'local',
search: vi.fn(() => []),
} satisfies ActionItem,
}))
vi.mock('../workflow-nodes', () => ({
workflowNodesAction: {
key: '@node',
@@ -85,33 +77,31 @@ vi.mock('../rag-pipeline-nodes', () => ({
} satisfies ActionItem,
}))
vi.mock('../commands/registry')
describe('createActions', () => {
it('returns only global actions outside graph pages', () => {
expect(createActions(false, false)).toEqual(
expect(createActions(slash, false, false)).toEqual(
expect.objectContaining({ slash: expect.any(Object), app: expect.any(Object) }),
)
expect(createActions(false, false)).not.toHaveProperty('node')
expect(createActions(false, false)).toHaveProperty('skill')
expect(createActions(false, false)).not.toHaveProperty('agent')
expect(createActions(slash, false, false)).not.toHaveProperty('node')
expect(createActions(slash, false, false)).toHaveProperty('skill')
expect(createActions(slash, false, false)).not.toHaveProperty('agent')
})
it('applies workspace availability to skill and agent scopes', () => {
const actions = createActions(false, false, { agents: true, skills: false })
const actions = createActions(slash, false, false, { agents: true, skills: false })
expect(actions).toHaveProperty('agent')
expect(actions).not.toHaveProperty('skill')
})
it('uses the workflow-owned node action on workflow pages', () => {
expect((createActions(true, false) as Record<string, ActionItem>).node!.title).toBe(
expect((createActions(slash, true, false) as Record<string, ActionItem>).node!.title).toBe(
'Workflow Nodes',
)
})
it('uses the RAG-owned node action when both graph flags are true', () => {
expect((createActions(true, true) as Record<string, ActionItem>).node!.title).toBe(
expect((createActions(slash, true, true) as Record<string, ActionItem>).node!.title).toBe(
'RAG Pipeline Nodes',
)
})
@@ -119,7 +109,7 @@ describe('createActions', () => {
describe('getActionSearchTerm', () => {
it('removes either the action key or shortcut', () => {
const action = createActions(false, false).knowledge
const action = createActions(slash, false, false).knowledge
expect(getActionSearchTerm('@knowledge vector store', action)).toBe('vector store')
expect(getActionSearchTerm('@kb vector store', action)).toBe('vector store')
@@ -127,11 +117,7 @@ describe('getActionSearchTerm', () => {
})
describe('matchAction', () => {
const actions = createActions(false, false, { agents: true, skills: true })
beforeEach(() => {
vi.mocked(slashCommandRegistry.getAvailableCommands).mockReturnValue([])
})
const actions = createActions(slash, false, false, { agents: true, skills: true })
it.each([
['@app query', '@app'],
@@ -149,21 +135,10 @@ describe('matchAction', () => {
})
it('requires a delimiter for submenu commands and leaves direct commands in the picker', () => {
vi.mocked(slashCommandRegistry.getAvailableCommands).mockReturnValue([
{
name: 'theme',
aliases: ['appearance'],
mode: 'submenu',
description: '',
search: vi.fn(() => []),
},
{ name: 'docs', mode: 'direct', description: '', search: vi.fn(() => []) },
])
expect(matchAction('/theme', actions)).toBeUndefined()
expect(matchAction('/theme ', actions)?.key).toBe('/')
expect(matchAction('/theme dark', actions)?.key).toBe('/')
expect(matchAction('/appearance dark', actions)?.key).toBe('/')
expect(matchAction('/lang english', actions)?.key).toBe('/')
expect(matchAction('/docs', actions)).toBeUndefined()
expect(matchAction('/the', actions)).toBeUndefined()
})
@@ -1,68 +0,0 @@
import { executeCommand, registerCommands, unregisterCommands } from '../command-bus'
describe('command-bus', () => {
afterEach(() => {
unregisterCommands(['test.a', 'test.b', 'test.c', 'async.cmd', 'noop'])
})
describe('registerCommands / executeCommand', () => {
it('registers and executes a sync command', async () => {
const handler = vi.fn()
registerCommands({ 'test.a': handler })
await executeCommand('test.a', { value: 42 })
expect(handler).toHaveBeenCalledWith({ value: 42 })
})
it('registers and executes an async command', async () => {
const handler = vi.fn().mockResolvedValue(undefined)
registerCommands({ 'async.cmd': handler })
await executeCommand('async.cmd')
expect(handler).toHaveBeenCalled()
})
it('registers multiple commands at once', async () => {
const handlerA = vi.fn()
const handlerB = vi.fn()
registerCommands({ 'test.a': handlerA, 'test.b': handlerB })
await executeCommand('test.a')
await executeCommand('test.b')
expect(handlerA).toHaveBeenCalled()
expect(handlerB).toHaveBeenCalled()
})
it('silently ignores unregistered command names', async () => {
await expect(executeCommand('nonexistent')).resolves.toBeUndefined()
})
it('passes undefined args when not provided', async () => {
const handler = vi.fn()
registerCommands({ 'test.c': handler })
await executeCommand('test.c')
expect(handler).toHaveBeenCalledWith(undefined)
})
})
describe('unregisterCommands', () => {
it('removes commands so they can no longer execute', async () => {
const handler = vi.fn()
registerCommands({ 'test.a': handler })
unregisterCommands(['test.a'])
await executeCommand('test.a')
expect(handler).not.toHaveBeenCalled()
})
it('handles unregistering non-existent commands gracefully', () => {
expect(() => unregisterCommands(['nope'])).not.toThrow()
})
})
})
@@ -0,0 +1,155 @@
import type { CommandContext } from '../types'
import { createInstance } from 'i18next'
import { renderToString } from 'react-dom/server'
import { accountCommand } from '../account'
import { slashCommandRegistry } from '../catalog'
import { discordCommand } from '../discord'
import { docsCommand } from '../docs'
import { goCommand } from '../go'
import { languageCommand } from '../language'
import { modelsCommand, SYSTEM_MODELS_PATH } from '../models'
import { refineCommand } from '../refine'
import { SlashCommandRegistry } from '../registry'
import { createSlashAction } from '../slash'
import { themeCommand } from '../theme'
import { createCommandContext } from './context'
const executeResult = (query: string, context: CommandContext) => {
const action = createSlashAction(context)
const result = slashCommandRegistry.search(query, context)[0]!
action.action?.(result)
}
describe('command journeys', () => {
it('lists and filters themes, and activates the selected theme', () => {
const context = createCommandContext()
expect(slashCommandRegistry.search('/theme', context).map((result) => result.id)).toEqual([
'system',
'light',
'dark',
])
executeResult('/theme dark', context)
expect(context.setTheme).toHaveBeenCalledExactlyOnceWith('dark')
})
it('finds a language by its native name and activates it through the alias', async () => {
const context = createCommandContext()
const result = slashCommandRegistry.search('/lang 简体中文', context)[0]!
expect(result.title).toBe('简体中文')
await slashCommandRegistry.execute(result.data.command, result.data.args ?? {}, context)
expect(context.setLocale).toHaveBeenCalledExactlyOnceWith('zh-Hans')
})
it('uses the current destination for direct and search-result activation', async () => {
for (const command of [accountCommand, modelsCommand, docsCommand, discordCommand]) {
const context = createCommandContext({
getDocsHomeUrl: () => 'https://enterprise.example/help/zh',
})
const registry = new SlashCommandRegistry([command])
await registry.execute(command.name, {}, context)
const result = registry.search(`/${command.name}`, context)[0]!
await registry.execute(result.data.command, result.data.args ?? {}, context)
const callback =
command === accountCommand || command === modelsCommand
? context.navigate
: context.openExternal
const destination =
command === accountCommand
? '/account'
: command === modelsCommand
? SYSTEM_MODELS_PATH
: command === docsCommand
? 'https://enterprise.example/help/zh'
: 'https://discord.gg/5AEfbxcd9k'
expect(callback).toHaveBeenCalledTimes(2)
expect(callback).toHaveBeenLastCalledWith(destination)
}
})
it('hides unavailable destinations and blocks a result selected before access changed', async () => {
const context = createCommandContext()
const agents = goCommand.search('agents', context)[0]!
const restricted = createCommandContext({ agentsAvailable: false, skillsAvailable: false })
expect(goCommand.search('', restricted).map((result) => result.id)).not.toContain('go-agents')
expect(goCommand.search('skills', restricted)).toEqual([])
await goCommand.execute(agents.data.args ?? {}, restricted)
expect(restricted.navigate).not.toHaveBeenCalled()
await goCommand.execute(agents.data.args ?? {}, context)
expect(context.navigate).toHaveBeenCalledWith('/agents')
})
it.each(['workflow', 'advanced-chat'] as const)(
'refines only the current %s app',
async (mode) => {
const context = createCommandContext({ currentApp: { id: 'current-app', mode } })
const registry = new SlashCommandRegistry([refineCommand])
await registry.execute('refine', {}, context)
expect(context.openGenerator).toHaveBeenCalledWith({
intent: 'refine',
mode,
currentAppId: 'current-app',
currentAppMode: mode,
})
const outsideStudio = createCommandContext()
await registry.execute('refine', {}, outsideStudio)
expect(outsideStudio.openGenerator).not.toHaveBeenCalled()
},
)
it('keeps language, permissions, docs links, and callbacks isolated between contexts', async () => {
const first = createCommandContext({
agentsAvailable: false,
getDocsHomeUrl: () => 'https://one.example/docs',
})
const second = createCommandContext({ getDocsHomeUrl: () => 'https://two.example/docs' })
expect(goCommand.search('agents', first)).toEqual([])
expect(goCommand.search('agents', second)).toHaveLength(1)
await docsCommand.execute({}, first)
await docsCommand.execute({}, second)
executeResult('/theme dark', second)
expect(first.openExternal).toHaveBeenCalledExactlyOnceWith('https://one.example/docs')
expect(second.openExternal).toHaveBeenCalledExactlyOnceWith('https://two.example/docs')
expect(first.setTheme).not.toHaveBeenCalled()
expect(second.setTheme).toHaveBeenCalledExactlyOnceWith('dark')
})
it('renders localized commands on the server without a browser or activation effects', async () => {
const i18n = createInstance()
await i18n.init({
lng: 'en-US',
resources: {
'en-US': { app: { 'gotoAnything.actions.themeDark': 'Dark' } },
'zh-Hans': { app: { 'gotoAnything.actions.themeDark': '深色' } },
},
})
const first = createCommandContext({ t: i18n.t as CommandContext['t'] })
const second = createCommandContext({ t: i18n.t as CommandContext['t'], locale: 'zh-Hans' })
vi.stubGlobal('window', undefined)
try {
const renderCommands = (context: CommandContext) =>
renderToString(
<ul>
{themeCommand.search('dark', context).map((result) => (
<li key={result.id}>
{result.title}
{result.icon}
</li>
))}
</ul>,
)
expect(renderCommands(first)).toContain('Dark')
expect(renderCommands(second)).toContain('深色')
expect(renderCommands(first)).toContain('Dark')
for (const context of [first, second]) {
slashCommandRegistry.search('/', context)
languageCommand.search('', context)
expect(context.setTheme).not.toHaveBeenCalled()
expect(context.openExternal).not.toHaveBeenCalled()
expect(context.navigate).not.toHaveBeenCalled()
expect(context.openGenerator).not.toHaveBeenCalled()
}
} finally {
vi.unstubAllGlobals()
}
})
})
@@ -0,0 +1,20 @@
import type { CommandContext } from '../types'
import { withSelectorKey } from '@/test/i18n-mock'
export function createCommandContext(overrides: Partial<CommandContext> = {}): CommandContext {
return {
t: withSelectorKey((key: string) => key) as CommandContext['t'],
locale: 'en-US',
agentsAvailable: true,
skillsAvailable: true,
currentApp: null,
setTheme: vi.fn(),
setLocale: vi.fn().mockResolvedValue(undefined),
getDocsHomeUrl: () => 'https://docs.dify.ai/en',
onError: vi.fn(),
navigate: vi.fn(),
openExternal: vi.fn(),
openGenerator: vi.fn(),
...overrides,
}
}
@@ -1,46 +1,15 @@
import { executeCommand } from '../command-bus'
import { createCommand } from '../create'
// We spy on the store at module scope so the `create.open` handler that
// register() pushes into the command bus can be observed by the tests.
const mockOpenGenerator = vi.fn()
vi.mock('@/app/components/workflow/workflow-generator/store', () => ({
useWorkflowGeneratorStore: {
getState: () => ({ openGenerator: mockOpenGenerator }),
},
}))
// Controllable app-store state — the handler reads `appDetail` to decide
// whether to thread the current Studio app through to the generator. Mutated
// per-test; getState() reads it lazily so updates land after the mock factory.
const mockAppStore: { appDetail: { id: string; mode: string } | undefined } = {
appDetail: undefined,
}
vi.mock('@/app/components/app/store', () => ({
useStore: {
getState: () => ({ appDetail: mockAppStore.appDetail }),
},
}))
import { createCommandContext } from './context'
let context = createCommandContext()
describe('/create slash command', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('handler metadata', () => {
// The slash registry relies on this metadata to route /create through the
// submenu UX rather than executing immediately.
it('should expose submenu mode with the expected name and aliases', () => {
expect(createCommand.mode).toBe('submenu')
expect(createCommand.name).toBe('create')
expect(createCommand.aliases).toEqual(['new', 'generate'])
})
context = createCommandContext()
})
describe('search()', () => {
// An empty arg list should surface every option (Auto first); the submenu
// uses this to render its initial list when the user types just `/create`.
it('should surface auto, workflow and chatflow when args is empty', async () => {
const results = await createCommand.search('')
const results = await createCommand.search('', context)
expect(results.map((r) => r.id)).toEqual([
'create-auto',
'create-workflow',
@@ -48,50 +17,39 @@ describe('/create slash command', () => {
])
})
// Typing a partial keyword should narrow the list and each result should
// carry the right command-bus payload so the navigation hook can fire it.
it('should filter by query and attach the right command payload', async () => {
const results = await createCommand.search('chat')
const results = await createCommand.search('chat', context)
expect(results.map((r) => r.id)).toEqual(['create-chatflow'])
expect(results[0]!.data.command).toBe('create.open')
expect(results[0]!.data.command).toBe('create')
expect(results[0]!.data.args).toEqual({ mode: 'advanced-chat', auto: false, instruction: '' })
})
// The Auto option carries the auto flag so the modal opens in auto-mode.
it('should flag the auto option so the planner picks the app type', async () => {
const results = await createCommand.search('')
const results = await createCommand.search('', context)
expect(results[0]!.id).toBe('create-auto')
expect(results[0]!.data.args).toEqual({ mode: 'advanced-chat', auto: true, instruction: '' })
})
// A non-matching single-token query returns an empty list rather than
// throwing, so the goto-anything dialog can render an empty-state.
it('should return an empty list when a single-token query matches nothing', async () => {
const results = await createCommand.search('zzz')
const results = await createCommand.search('zzz', context)
expect(results).toEqual([])
})
// Labels/descriptions must be localised through i18n (ns: 'app') rather
// than hardcoded English, so the palette renders in the user's language.
it('should source titles and descriptions from i18n keys', async () => {
const results = await createCommand.search('')
const results = await createCommand.search('', context)
expect(results[1]!.title).toBe('gotoAnything.actions.createWorkflow')
expect(results[1]!.description).toBe('gotoAnything.actions.createWorkflowDesc')
expect(results[2]!.title).toBe('gotoAnything.actions.createChatflow')
expect(results[2]!.description).toBe('gotoAnything.actions.createChatflowDesc')
})
// The localised label is also searchable, not just the id — a token that
// appears only in the (mocked) title key still narrows the list.
it('should filter by the localised label, not just the id', async () => {
const results = await createCommand.search('createChatflow')
const results = await createCommand.search('createChatflow', context)
expect(results.map((r) => r.id)).toEqual(['create-chatflow'])
})
// Inline capture: a leading mode word selects that option and the rest of
// the text becomes the pre-filled instruction surfaced as the description.
it('should capture a trailing instruction when the first word names a mode', async () => {
const results = await createCommand.search('workflow summarize a URL')
const results = await createCommand.search('workflow summarize a URL', context)
expect(results.map((r) => r.id)).toEqual(['create-workflow'])
expect(results[0]!.data.args).toEqual({
mode: 'workflow',
@@ -101,10 +59,8 @@ describe('/create slash command', () => {
expect(results[0]!.description).toBe('summarize a URL')
})
// Inline capture without a leading mode word keeps every option, each
// pre-filled with the full text so the user just picks the type.
it('should keep all options with the full text when no leading mode word', async () => {
const results = await createCommand.search('summarize a URL')
const results = await createCommand.search('summarize a URL', context)
expect(results.map((r) => r.id)).toEqual([
'create-auto',
'create-workflow',
@@ -116,60 +72,45 @@ describe('/create slash command', () => {
})
})
describe('register() — `create.open` command-bus handler', () => {
beforeEach(() => {
mockAppStore.appDetail = undefined
createCommand.register?.({} as never)
})
afterEach(() => {
createCommand.unregister?.()
})
// No Studio app open — the modal opens for new-app creation only.
describe('execute()', () => {
it('should open the generator with the requested mode when no Studio app is open', async () => {
await executeCommand('create.open', { mode: 'workflow' })
await createCommand.execute({ mode: 'workflow' }, context)
expect(mockOpenGenerator).toHaveBeenCalledWith({
expect(context.openGenerator).toHaveBeenCalledWith({
mode: 'workflow',
autoMode: false,
initialInstruction: '',
})
})
// Inline-captured instruction threads through to the modal.
it('should thread the captured instruction through to the generator', async () => {
await executeCommand('create.open', { mode: 'workflow', instruction: 'summarize a URL' })
await createCommand.execute({ mode: 'workflow', instruction: 'summarize a URL' }, context)
expect(mockOpenGenerator).toHaveBeenCalledWith({
expect(context.openGenerator).toHaveBeenCalledWith({
mode: 'workflow',
autoMode: false,
initialInstruction: 'summarize a URL',
})
})
// Auto-mode always creates a new app, even with a matching Studio open,
// because the planner may resolve a different type than the open canvas.
it('should open new-app auto-mode even when a matching Studio app is open', async () => {
mockAppStore.appDetail = { id: 'abc-123', mode: 'advanced-chat' }
context.currentApp = { id: 'abc-123', mode: 'advanced-chat' }
await executeCommand('create.open', { mode: 'advanced-chat', auto: true })
await createCommand.execute({ mode: 'advanced-chat', auto: true }, context)
expect(mockOpenGenerator).toHaveBeenCalledWith({
expect(context.openGenerator).toHaveBeenCalledWith({
mode: 'advanced-chat',
autoMode: true,
initialInstruction: '',
})
})
// In-Studio create-and-apply: a matching graph-based app threads id + mode
// through so the modal can offer "Apply to current draft".
it('should thread the current app context when a matching Studio app is open', async () => {
mockAppStore.appDetail = { id: 'abc-123', mode: 'workflow' }
context.currentApp = { id: 'abc-123', mode: 'workflow' }
await executeCommand('create.open', { mode: 'workflow' })
await createCommand.execute({ mode: 'workflow' }, context)
expect(mockOpenGenerator).toHaveBeenCalledWith({
expect(context.openGenerator).toHaveBeenCalledWith({
mode: 'workflow',
currentAppId: 'abc-123',
currentAppMode: 'workflow',
@@ -177,60 +118,16 @@ describe('/create slash command', () => {
})
})
// Mode mismatch must NOT capture currentAppId — applying a chatflow graph
// onto a workflow draft is the dead-end we explicitly avoid.
it('should fall back to new-app only when the picked mode differs from the open app', async () => {
mockAppStore.appDetail = { id: 'abc-123', mode: 'workflow' }
context.currentApp = { id: 'abc-123', mode: 'workflow' }
await executeCommand('create.open', { mode: 'advanced-chat' })
await createCommand.execute({ mode: 'advanced-chat' }, context)
expect(mockOpenGenerator).toHaveBeenCalledWith({
expect(context.openGenerator).toHaveBeenCalledWith({
mode: 'advanced-chat',
autoMode: false,
initialInstruction: '',
})
})
// Non-graph Studio apps (Chat / Agent / Completion) have no canvas to apply
// onto, so the handler ignores them and opens new-app only.
it('should ignore non-graph app modes and open new-app only', async () => {
mockAppStore.appDetail = { id: 'abc-123', mode: 'chat' }
await executeCommand('create.open', { mode: 'workflow' })
expect(mockOpenGenerator).toHaveBeenCalledWith({
mode: 'workflow',
autoMode: false,
initialInstruction: '',
})
})
// Defensive fallback: a missing mode still opens the generator safely.
it('should default to workflow mode when no args are passed', async () => {
await executeCommand('create.open')
expect(mockOpenGenerator).toHaveBeenCalledWith({
mode: 'workflow',
autoMode: false,
initialInstruction: '',
})
})
})
describe('unregister()', () => {
// After unregister, the bus must drop the handler so a later execute call
// becomes a silent no-op (prevents stale references between mounts).
it('should remove the command-bus handler so it stops firing', async () => {
createCommand.register?.({} as never)
createCommand.unregister?.()
Object.defineProperty(window, 'location', {
writable: true,
value: { pathname: '/apps' },
})
await executeCommand('create.open', { mode: 'workflow' })
expect(mockOpenGenerator).not.toHaveBeenCalled()
})
})
})
@@ -1,294 +0,0 @@
/**
* Tests for direct-mode commands that share similar patterns:
* docs, account, discord, models
*
* Each command: opens a URL or navigates, has direct mode, and registers a navigation command.
*/
import { accountCommand } from '../account'
import { registerCommands, unregisterCommands } from '../command-bus'
import { discordCommand } from '../discord'
import { docsCommand } from '../docs'
import { modelsCommand, SYSTEM_MODELS_PATH } from '../models'
vi.mock('../command-bus')
const mockT = vi.fn((key: string) => key)
vi.mock('react-i18next', async () => {
const { withSelectorKey } = await import('@/test/i18n-mock')
return {
getI18n: () => ({
t: withSelectorKey((key: string) => mockT(key)),
language: 'en',
}),
}
})
describe('docsCommand', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
docsCommand.unregister?.()
})
it('has correct metadata', () => {
expect(docsCommand.name).toBe('docs')
expect(docsCommand.mode).toBe('direct')
expect(docsCommand.execute).toBeDefined()
})
it('execute opens documentation in new tab', () => {
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
docsCommand.register?.({ getDocsHomeUrl: () => 'https://docs.dify.ai/en/home' })
docsCommand.execute?.()
expect(openSpy).toHaveBeenCalledWith(
'https://docs.dify.ai/en/home',
'_blank',
'noopener,noreferrer',
)
openSpy.mockRestore()
})
it('execute uses the documentation URL registered by the provider', () => {
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
docsCommand.register?.({
getDocsHomeUrl: () => 'https://enterprise-docs.dify.ai/en/',
})
docsCommand.execute?.()
expect(openSpy).toHaveBeenCalledWith(
'https://enterprise-docs.dify.ai/en/',
'_blank',
'noopener,noreferrer',
)
openSpy.mockRestore()
})
it('search returns a single doc result', async () => {
const results = await docsCommand.search('', 'en')
expect(results).toHaveLength(1)
expect(results[0]).toMatchObject({
id: 'doc',
type: 'command',
data: { command: 'navigation.doc', args: {} },
})
})
it('search uses fallback description when i18n returns empty', async () => {
mockT.mockImplementation((key: string) => (key.includes('docDesc') ? '' : key))
const results = await docsCommand.search('', 'en')
expect(results[0]!.description).toBe('Open help documentation')
mockT.mockImplementation((key: string) => key)
})
it('registers navigation.doc command', () => {
docsCommand.register?.({ getDocsHomeUrl: () => 'https://docs.dify.ai/en/home' })
expect(registerCommands).toHaveBeenCalledWith({ 'navigation.doc': expect.any(Function) })
})
it('registered handler opens doc URL with correct locale', async () => {
docsCommand.register?.({
getDocsHomeUrl: () => 'https://enterprise-docs.dify.ai/en/',
})
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
const handlers = vi.mocked(registerCommands).mock.calls[0]![0]
await handlers['navigation.doc']!()
expect(openSpy).toHaveBeenCalledWith(
'https://enterprise-docs.dify.ai/en/',
'_blank',
'noopener,noreferrer',
)
openSpy.mockRestore()
})
it('unregisters navigation.doc command', () => {
docsCommand.unregister?.()
expect(unregisterCommands).toHaveBeenCalledWith(['navigation.doc'])
})
})
describe('accountCommand', () => {
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(accountCommand.name).toBe('account')
expect(accountCommand.mode).toBe('direct')
expect(accountCommand.execute).toBeDefined()
})
it('execute navigates to /account', () => {
Object.defineProperty(window, 'location', { value: { href: '' }, writable: true })
accountCommand.execute?.()
expect(window.location.href).toBe('/account')
})
it('search returns account result', async () => {
const results = await accountCommand.search('', 'en')
expect(results).toHaveLength(1)
expect(results[0]).toMatchObject({
id: 'account',
type: 'command',
data: { command: 'navigation.account', args: {} },
})
})
it('registers navigation.account command', () => {
accountCommand.register?.({} as Record<string, never>)
expect(registerCommands).toHaveBeenCalledWith({ 'navigation.account': expect.any(Function) })
})
it('unregisters navigation.account command', () => {
accountCommand.unregister?.()
expect(unregisterCommands).toHaveBeenCalledWith(['navigation.account'])
})
})
describe('discordCommand', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('has correct metadata', () => {
expect(discordCommand.name).toBe('discord')
expect(discordCommand.mode).toBe('direct')
expect(discordCommand.execute).toBeDefined()
})
it('execute opens Discord URL', () => {
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
discordCommand.execute?.()
expect(openSpy).toHaveBeenCalledWith(
'https://discord.gg/5AEfbxcd9k',
'_blank',
'noopener,noreferrer',
)
openSpy.mockRestore()
})
it('search returns Discord result', async () => {
const results = await discordCommand.search('', 'en')
expect(results).toHaveLength(1)
expect(results[0]).toMatchObject({
id: 'discord',
type: 'command',
data: { command: 'navigation.discord' },
})
})
it('search uses fallback description when i18n returns empty', async () => {
mockT.mockImplementation((key: string) => (key.includes('discordDesc') ? '' : key))
const results = await discordCommand.search('', 'en')
expect(results[0]!.description).toBe('Open Discord community')
mockT.mockImplementation((key: string) => key)
})
it('registers navigation.discord command', () => {
discordCommand.register?.({} as Record<string, never>)
expect(registerCommands).toHaveBeenCalledWith({ 'navigation.discord': expect.any(Function) })
})
it('registered handler opens URL from args', async () => {
discordCommand.register?.({} as Record<string, never>)
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
const handlers = vi.mocked(registerCommands).mock.calls[0]![0]
await handlers['navigation.discord']!({ url: 'https://custom-url.com' })
expect(openSpy).toHaveBeenCalledWith('https://custom-url.com', '_blank', 'noopener,noreferrer')
openSpy.mockRestore()
})
it('registered handler falls back to default URL when no args', async () => {
discordCommand.register?.({} as Record<string, never>)
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
const handlers = vi.mocked(registerCommands).mock.calls[0]![0]
await handlers['navigation.discord']!()
expect(openSpy).toHaveBeenCalledWith(
'https://discord.gg/5AEfbxcd9k',
'_blank',
'noopener,noreferrer',
)
openSpy.mockRestore()
})
it('unregisters navigation.discord command', () => {
discordCommand.unregister?.()
expect(unregisterCommands).toHaveBeenCalledWith(['navigation.discord'])
})
})
describe('modelsCommand', () => {
let originalHref: string
beforeEach(() => {
vi.clearAllMocks()
originalHref = window.location.href
})
afterEach(() => {
Object.defineProperty(window, 'location', { value: { href: originalHref }, writable: true })
})
it('has direct command metadata', () => {
expect(modelsCommand.name).toBe('models')
expect(modelsCommand.mode).toBe('direct')
expect(modelsCommand.execute).toBeDefined()
})
it('navigates to model provider with the system model dialog URL state', () => {
Object.defineProperty(window, 'location', { value: { href: '' }, writable: true })
modelsCommand.execute?.()
expect(window.location.href).toBe(SYSTEM_MODELS_PATH)
})
it('search returns the system models result', async () => {
const results = await modelsCommand.search('', 'en')
expect(results).toHaveLength(1)
expect(results[0]).toMatchObject({
id: 'models',
type: 'command',
data: { command: 'navigation.models' },
})
})
it('registers a navigation command that opens the system model route', async () => {
Object.defineProperty(window, 'location', { value: { href: '' }, writable: true })
modelsCommand.register?.({} as Record<string, never>)
const handlers = vi.mocked(registerCommands).mock.calls[0]![0]
await handlers['navigation.models']!()
expect(window.location.href).toBe(SYSTEM_MODELS_PATH)
})
it('unregisters navigation.models command', () => {
modelsCommand.unregister?.()
expect(unregisterCommands).toHaveBeenCalledWith(['navigation.models'])
})
})
@@ -1,130 +0,0 @@
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 () => {
goCommand.register?.({ agentsAvailable: true, skillsAvailable: true })
const results = await goCommand.search('', 'en')
expect(results.map((r) => r.id)).toEqual([
'go-apps',
'go-datasets',
'go-agents',
'go-skills',
'go-plugins',
'go-tools',
'go-home',
'go-account',
])
})
it('hides agent and skill destinations when their navigation is unavailable', async () => {
goCommand.register?.({ agentsAvailable: false, skillsAvailable: false })
const results = await goCommand.search('', 'en')
expect(results.map((result) => result.id)).not.toEqual(
expect.arrayContaining(['go-agents', 'go-skills']),
)
})
it('routes Home to the consolidated home page', async () => {
const results = await goCommand.search('home', 'en')
expect(results[0]).toMatchObject({
id: 'go-home',
title: 'Home',
description: '/',
data: { command: 'navigation.go', args: { path: '/' } },
})
})
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?.({ agentsAvailable: false, skillsAvailable: true })
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?.({ agentsAvailable: false, skillsAvailable: true })
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?.({ agentsAvailable: false, skillsAvailable: true })
const handlers = vi.mocked(registerCommands).mock.calls[0]![0]
await handlers['navigation.go']!()
await handlers['navigation.go']!({})
expect(window.location.href).toBe('/current')
})
})
})
@@ -1,82 +0,0 @@
import { registerCommands, unregisterCommands } from '../command-bus'
import { languageCommand } from '../language'
vi.mock('../command-bus')
vi.mock('@/i18n/language', () => ({
languages: [
{ value: 'en-US', name: 'English', supported: true },
{ value: 'zh-Hans', name: '简体中文', supported: true },
{ value: 'ja-JP', name: '日本語', supported: true },
{ value: 'unsupported', name: 'Unsupported', supported: false },
],
}))
describe('languageCommand', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('has correct metadata', () => {
expect(languageCommand.name).toBe('language')
expect(languageCommand.aliases).toEqual(['lang'])
expect(languageCommand.mode).toBe('submenu')
expect(languageCommand.execute).toBeUndefined()
})
describe('search', () => {
it('returns all supported languages when query is empty', async () => {
const results = await languageCommand.search('', 'en')
expect(results).toHaveLength(3) // 3 supported languages
expect(results.every((r) => r.type === 'command')).toBe(true)
})
it('filters languages by name query', async () => {
const results = await languageCommand.search('english', 'en')
expect(results).toHaveLength(1)
expect(results[0]!.id).toBe('lang-en-US')
})
it('filters languages by value query', async () => {
const results = await languageCommand.search('zh', 'en')
expect(results).toHaveLength(1)
expect(results[0]!.id).toBe('lang-zh-Hans')
})
it('returns command data with i18n.set command', async () => {
const results = await languageCommand.search('', 'en')
results.forEach((r) => {
expect(r.data.command).toBe('i18n.set')
expect(r.data.args).toHaveProperty('locale')
})
})
})
describe('register / unregister', () => {
it('registers i18n.set command', () => {
languageCommand.register?.({ setLocale: vi.fn() })
expect(registerCommands).toHaveBeenCalledWith({ 'i18n.set': expect.any(Function) })
})
it('unregisters i18n.set command', () => {
languageCommand.unregister?.()
expect(unregisterCommands).toHaveBeenCalledWith(['i18n.set'])
})
it('registered handler calls setLocale with correct locale', async () => {
const setLocale = vi.fn().mockResolvedValue(undefined)
vi.mocked(registerCommands).mockImplementation((map) => {
map['i18n.set']?.({ locale: 'zh-Hans' })
})
languageCommand.register?.({ setLocale })
expect(setLocale).toHaveBeenCalledWith('zh-Hans')
})
})
})
@@ -1,139 +0,0 @@
import { executeCommand } from '../command-bus'
import { refineCommand } from '../refine'
// Spy on the generator store so we can observe what /refine opens it with.
const mockOpenGenerator = vi.fn()
vi.mock('@/app/components/workflow/workflow-generator/store', () => ({
useWorkflowGeneratorStore: {
getState: () => ({ openGenerator: mockOpenGenerator }),
},
}))
// Controllable app-store state — /refine reads appDetail to gate availability
// and to pick the mode + id it refines. Mutated per-test; read lazily.
const mockAppStore: { appDetail: { id: string; mode: string } | undefined } = {
appDetail: undefined,
}
vi.mock('@/app/components/app/store', () => ({
useStore: {
getState: () => ({ appDetail: mockAppStore.appDetail }),
},
}))
describe('/refine slash command', () => {
beforeEach(() => {
vi.clearAllMocks()
mockAppStore.appDetail = undefined
})
describe('handler metadata', () => {
it('should be a direct command named refine with the expected alias', () => {
expect(refineCommand.mode).toBe('direct')
expect(refineCommand.name).toBe('refine')
expect(refineCommand.aliases).toEqual(['improve'])
})
})
describe('isAvailable()', () => {
// /refine only makes sense inside a graph-based Studio — elsewhere there's
// no draft graph to refine, so the command must hide itself.
it('should be unavailable when no app is open', () => {
expect(refineCommand.isAvailable?.()).toBe(false)
})
it('should be available in a Workflow Studio', () => {
mockAppStore.appDetail = { id: 'app-1', mode: 'workflow' }
expect(refineCommand.isAvailable?.()).toBe(true)
})
it('should be available in an Advanced-Chat Studio', () => {
mockAppStore.appDetail = { id: 'app-1', mode: 'advanced-chat' }
expect(refineCommand.isAvailable?.()).toBe(true)
})
it('should be unavailable for non-graph apps (chat / agent / completion)', () => {
mockAppStore.appDetail = { id: 'app-1', mode: 'chat' }
expect(refineCommand.isAvailable?.()).toBe(false)
})
})
describe('execute()', () => {
// The core behaviour: open the generator in refine intent, threading the
// current app's id + mode so the modal fetches its draft as context.
it('should open the generator in refine intent for a Workflow Studio', () => {
mockAppStore.appDetail = { id: 'app-1', mode: 'workflow' }
refineCommand.execute?.()
expect(mockOpenGenerator).toHaveBeenCalledWith({
intent: 'refine',
mode: 'workflow',
currentAppId: 'app-1',
currentAppMode: 'workflow',
})
})
it('should map advanced-chat apps to the advanced-chat generator mode', () => {
mockAppStore.appDetail = { id: 'app-2', mode: 'advanced-chat' }
refineCommand.execute?.()
expect(mockOpenGenerator).toHaveBeenCalledWith({
intent: 'refine',
mode: 'advanced-chat',
currentAppId: 'app-2',
currentAppMode: 'advanced-chat',
})
})
it('should be a no-op when no graph-based app is open', () => {
mockAppStore.appDetail = { id: 'app-3', mode: 'chat' }
refineCommand.execute?.()
expect(mockOpenGenerator).not.toHaveBeenCalled()
})
})
describe('search()', () => {
// The submenu/result list renders one localised entry carrying the
// refine.open command-bus payload.
it('should return a single localised refine result', async () => {
const results = await refineCommand.search('')
expect(results).toHaveLength(1)
expect(results[0]!.id).toBe('refine-current')
expect(results[0]!.title).toBe('gotoAnything.actions.refineTitle')
expect(results[0]!.data.command).toBe('refine.open')
})
})
describe('register() — `refine.open` command-bus handler', () => {
beforeEach(() => {
refineCommand.register?.({} as never)
})
afterEach(() => {
refineCommand.unregister?.()
})
it('should open the generator via the command bus too', async () => {
mockAppStore.appDetail = { id: 'app-1', mode: 'workflow' }
await executeCommand('refine.open', {})
expect(mockOpenGenerator).toHaveBeenCalledWith({
intent: 'refine',
mode: 'workflow',
currentAppId: 'app-1',
currentAppMode: 'workflow',
})
})
it('should stop firing after unregister', async () => {
mockAppStore.appDetail = { id: 'app-1', mode: 'workflow' }
refineCommand.unregister?.()
await executeCommand('refine.open', {})
expect(mockOpenGenerator).not.toHaveBeenCalled()
})
})
})
@@ -1,302 +1,100 @@
import type { SlashCommandHandler } from '../types'
import type { SlashCommand } from '../types'
import { SlashCommandRegistry } from '../registry'
import { createCommandContext } from './context'
function createHandler(overrides: Partial<SlashCommandHandler> = {}): SlashCommandHandler {
return {
name: 'test',
description: 'Test command',
search: vi.fn().mockResolvedValue([]),
register: vi.fn(),
unregister: vi.fn(),
...overrides,
}
}
const context = createCommandContext()
const command = (overrides: Partial<SlashCommand> = {}): SlashCommand => ({
name: 'test',
description: 'Test',
search: () => [],
execute: vi.fn(),
...overrides,
})
describe('SlashCommandRegistry', () => {
let registry: SlashCommandRegistry
beforeEach(() => {
registry = new SlashCommandRegistry()
describe('command lookup and execution', () => {
it('resolves names and aliases without duplicating the command directory', () => {
const language = command({ name: 'language', aliases: ['lang'] })
const registry = new SlashCommandRegistry([language])
expect(registry.findCommand('lang')).toBe(language)
expect(registry.search('/', context).map((result) => result.title)).toEqual(['/language'])
})
it('publishes stable deduplicated snapshots when commands are registered or removed', () => {
const listener = vi.fn()
const unsubscribe = registry.subscribe(listener)
const empty = registry.getSnapshot()
const command = createHandler({ name: 'language', aliases: ['lang'] })
registry.register(command)
expect(listener).toHaveBeenCalledTimes(1)
const registered = registry.getSnapshot()
expect(registered).toEqual([command])
expect(registry.getSnapshot()).toBe(registered)
expect(registered).not.toBe(empty)
registry.unregister('language')
expect(registry.getSnapshot()).toEqual([])
expect(listener).toHaveBeenCalledTimes(2)
unsubscribe()
registry.register(command)
expect(listener).toHaveBeenCalledTimes(2)
it('rejects ambiguous command names and aliases', () => {
expect(
() =>
new SlashCommandRegistry([
command({ name: 'language', aliases: ['lang'] }),
command({ name: 'lang' }),
]),
).toThrow('Duplicate slash command: lang')
})
describe('register & findCommand', () => {
it('registers a handler and retrieves it by name', () => {
const handler = createHandler({ name: 'docs' })
registry.register(handler)
expect(registry.findCommand('docs')).toBe(handler)
})
it('registers aliases so handler is found by any alias', () => {
const handler = createHandler({ name: 'language', aliases: ['lang', 'l'] })
registry.register(handler)
expect(registry.findCommand('language')).toBe(handler)
expect(registry.findCommand('lang')).toBe(handler)
expect(registry.findCommand('l')).toBe(handler)
})
it('calls handler.register with provided deps', () => {
const handler = createHandler({ name: 'theme' })
const deps = { setTheme: vi.fn() }
registry.register(handler, deps)
expect(handler.register).toHaveBeenCalledWith(deps)
})
it('does not call handler.register when no deps provided', () => {
const handler = createHandler({ name: 'docs' })
registry.register(handler)
expect(handler.register).not.toHaveBeenCalled()
})
it('returns undefined for unknown command name', () => {
expect(registry.findCommand('nonexistent')).toBeUndefined()
})
it('forwards arguments through exact names, aliases, and partial names', () => {
const search = vi.fn(() => [])
const registry = new SlashCommandRegistry([
command({ name: 'language', aliases: ['lang'], search }),
])
for (const query of ['/language English', '/lang English', '/LANG English', '/lan English']) {
registry.search(query, context)
expect(search).toHaveBeenLastCalledWith('English', context)
}
})
describe('unregister', () => {
it('removes handler by name', () => {
const handler = createHandler({ name: 'docs' })
registry.register(handler)
registry.unregister('docs')
expect(registry.findCommand('docs')).toBeUndefined()
})
it('removes all aliases', () => {
const handler = createHandler({ name: 'language', aliases: ['lang'] })
registry.register(handler)
registry.unregister('language')
expect(registry.findCommand('language')).toBeUndefined()
expect(registry.findCommand('lang')).toBeUndefined()
})
it('calls handler.unregister', () => {
const handler = createHandler({ name: 'docs' })
registry.register(handler)
registry.unregister('docs')
expect(handler.unregister).toHaveBeenCalled()
})
it('is a no-op for unknown command', () => {
expect(() => registry.unregister('unknown')).not.toThrow()
})
it('filters unavailable commands and rechecks availability before execution', async () => {
const execute = vi.fn()
const registry = new SlashCommandRegistry([
command({
name: 'refine',
aliases: ['improve'],
execute,
isAvailable: (context) => context.currentApp !== null,
}),
])
const studio = createCommandContext({ currentApp: { id: 'app', mode: 'workflow' } })
expect(registry.search('/', studio)).toHaveLength(1)
expect(registry.search('/', context)).toEqual([])
expect(registry.search('/improve', context)).toEqual([])
await registry.execute('refine', {}, context)
expect(execute).not.toHaveBeenCalled()
await registry.execute('improve', {}, studio)
expect(execute).toHaveBeenCalledWith({}, studio)
})
describe('getAllCommands', () => {
it('returns deduplicated handlers', () => {
const h1 = createHandler({ name: 'theme', aliases: ['t'] })
const h2 = createHandler({ name: 'docs' })
registry.register(h1)
registry.register(h2)
const commands = registry.getAllCommands()
expect(commands).toHaveLength(2)
expect(commands).toContainEqual(expect.objectContaining({ name: 'theme' }))
expect(commands).toContainEqual(expect.objectContaining({ name: 'docs' }))
})
it('returns empty array when nothing registered', () => {
expect(registry.getAllCommands()).toEqual([])
})
})
describe('getAvailableCommands', () => {
it('includes commands without isAvailable guard', () => {
registry.register(createHandler({ name: 'docs' }))
expect(registry.getAvailableCommands()).toHaveLength(1)
})
it('includes commands where isAvailable returns true', () => {
registry.register(createHandler({ name: 'zen', isAvailable: () => true }))
expect(registry.getAvailableCommands()).toHaveLength(1)
})
it('excludes commands where isAvailable returns false', () => {
registry.register(createHandler({ name: 'zen', isAvailable: () => false }))
expect(registry.getAvailableCommands()).toHaveLength(0)
})
})
describe('search', () => {
it('returns root commands for "/"', async () => {
registry.register(createHandler({ name: 'theme', description: 'Change theme' }))
registry.register(createHandler({ name: 'docs', description: 'Open docs' }))
const results = await registry.search('/')
expect(results).toHaveLength(2)
expect(results[0]).toMatchObject({
id: expect.stringContaining('root-'),
type: 'command',
})
})
it('returns root commands for "/ "', async () => {
registry.register(createHandler({ name: 'theme' }))
const results = await registry.search('/ ')
expect(results).toHaveLength(1)
})
it('delegates to exact-match handler for "/theme dark"', async () => {
const mockResults = [
{ id: 'dark', title: 'Dark', description: '', type: 'command' as const, data: {} },
]
const handler = createHandler({
name: 'theme',
search: vi.fn().mockResolvedValue(mockResults),
})
registry.register(handler)
const results = await registry.search('/theme dark')
expect(handler.search).toHaveBeenCalledWith('dark', 'en')
expect(results).toEqual(mockResults)
})
it('delegates to exact-match handler for command without args', async () => {
const handler = createHandler({ name: 'docs', search: vi.fn().mockResolvedValue([]) })
registry.register(handler)
await registry.search('/docs')
expect(handler.search).toHaveBeenCalledWith('', 'en')
})
it('uses partial match when no exact match found', async () => {
const mockResults = [
{ id: '1', title: 'T', description: '', type: 'command' as const, data: {} },
]
const handler = createHandler({
name: 'theme',
search: vi.fn().mockResolvedValue(mockResults),
})
registry.register(handler)
const results = await registry.search('/the')
expect(results).toEqual(mockResults)
})
it('uses alias partial match', async () => {
const mockResults = [
{ id: '1', title: 'L', description: '', type: 'command' as const, data: {} },
]
const handler = createHandler({
name: 'language',
aliases: ['lang'],
search: vi.fn().mockResolvedValue(mockResults),
})
registry.register(handler)
const results = await registry.search('/lan')
expect(results).toEqual(mockResults)
})
it('falls back to fuzzy search when nothing matches', async () => {
registry.register(createHandler({ name: 'theme', description: 'Set theme' }))
const results = await registry.search('/hem')
expect(results).toHaveLength(1)
expect(results[0]!.title).toBe('/theme')
})
it('fuzzy search also matches aliases', async () => {
registry.register(
createHandler({ name: 'language', aliases: ['lang'], description: 'Set language' }),
)
const handler = registry.findCommand('language')
await registry.search('/lan')
expect(handler?.search).toHaveBeenCalled()
})
it('returns empty when handler.search throws', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const handler = createHandler({
name: 'broken',
search: vi.fn(() => {
throw new Error('fail')
it.each(['throw', 'reject'] as const)(
'reports an execution %s and keeps other commands usable',
async (failure) => {
const error = new Error('Command failed')
const context = createCommandContext()
const succeeding = vi.fn()
const registry = new SlashCommandRegistry([
command({
execute: () => {
if (failure === 'throw') throw error
return Promise.reject(error)
},
}),
})
registry.register(handler)
command({ name: 'other', execute: succeeding }),
])
await expect(registry.execute('test', {}, context)).resolves.toBeUndefined()
expect(context.onError).toHaveBeenCalledExactlyOnceWith(error)
await registry.execute('other', {}, context)
expect(succeeding).toHaveBeenCalledExactlyOnceWith({}, context)
expect(context.onError).toHaveBeenCalledTimes(1)
},
)
const results = await registry.search('/broken')
expect(results).toEqual([])
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('Command search failed'),
expect.any(Error),
)
warnSpy.mockRestore()
})
it('excludes unavailable commands from root listing', async () => {
registry.register(createHandler({ name: 'zen', isAvailable: () => false }))
registry.register(createHandler({ name: 'docs' }))
const results = await registry.search('/')
expect(results).toHaveLength(1)
expect(results[0]!.title).toBe('/docs')
})
it('skips unavailable handler in exact match', async () => {
registry.register(createHandler({ name: 'zen', isAvailable: () => false }))
const results = await registry.search('/zen')
expect(results).toEqual([])
})
it('passes locale to handler search', async () => {
const handler = createHandler({ name: 'theme', search: vi.fn().mockResolvedValue([]) })
registry.register(handler)
await registry.search('/theme light', 'zh')
expect(handler.search).toHaveBeenCalledWith('light', 'zh')
})
})
describe('getCommandDependencies', () => {
it('returns stored deps', () => {
const deps = { setTheme: vi.fn() }
registry.register(createHandler({ name: 'theme' }), deps)
expect(registry.getCommandDependencies('theme')).toBe(deps)
})
it('returns undefined when no deps stored', () => {
registry.register(createHandler({ name: 'docs' }))
expect(registry.getCommandDependencies('docs')).toBeUndefined()
})
it('contains command search failures and leaves other commands usable', () => {
const warning = vi.spyOn(console, 'warn').mockImplementation(() => {})
const registry = new SlashCommandRegistry([
command({
search: () => {
throw new Error('Unavailable')
},
}),
command({ name: 'other' }),
])
expect(registry.search('/test', context)).toEqual([])
expect(registry.search('/', context)).toHaveLength(2)
warning.mockRestore()
})
})
@@ -1,202 +0,0 @@
import type { SearchResult } from '../../types'
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
import { slashAction } from '../slash'
import { SlashCommandProvider } from '../slash-provider'
vi.mock('react-i18next', async () => {
const { createReactI18nextLanguageMock } = await import('@/test/i18n-mock')
return createReactI18nextLanguageMock('ja')
})
const {
mockSetTheme,
mockSetLocale,
mockExecuteCommand,
mockRegister,
mockSearch,
mockUnregister,
featureFlag,
} = vi.hoisted(() => ({
mockSetTheme: vi.fn(),
mockSetLocale: vi.fn(),
mockExecuteCommand: vi.fn(),
mockRegister: vi.fn(),
mockSearch: vi.fn(),
mockUnregister: vi.fn(),
// Mutable holder so each test can flip the feature-preview flag before render.
featureFlag: { enabled: false },
}))
vi.mock('@/config', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/config')>()
return {
...actual,
get ENABLE_FEATURE_PREVIEW() {
return featureFlag.enabled
},
}
})
vi.mock('next-themes', () => ({
useTheme: () => ({
setTheme: mockSetTheme,
}),
}))
vi.mock('@/i18n/client', () => ({
setLocaleOnClient: mockSetLocale,
}))
vi.mock('@/features/agent-v2/feature-flag', () => ({
isAgentV2Enabled: () => true,
}))
vi.mock('../command-bus', () => ({
executeCommand: (...args: unknown[]) => mockExecuteCommand(...args),
}))
vi.mock('../registry', () => ({
slashCommandRegistry: {
register: (...args: unknown[]) => mockRegister(...args),
search: (...args: unknown[]) => mockSearch(...args),
unregister: (...args: unknown[]) => mockUnregister(...args),
},
}))
describe('slashAction', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should expose translated title and description', () => {
expect(slashAction.title).toBe('gotoAnything.actions.slashTitle')
expect(slashAction.description).toBe('gotoAnything.actions.slashDesc')
})
it('should execute command results and ignore non-command results', () => {
slashAction.action?.({
id: 'cmd-1',
title: 'Command',
type: 'command',
data: {
command: 'navigation.docs',
args: { path: '/docs' },
},
} as SearchResult)
slashAction.action?.({
id: 'app-1',
title: 'App',
type: 'app',
data: {} as never,
} as SearchResult)
expect(mockExecuteCommand).toHaveBeenCalledTimes(1)
expect(mockExecuteCommand).toHaveBeenCalledWith('navigation.docs', { path: '/docs' })
})
it('should delegate search to the slash command registry with the active language', async () => {
mockSearch.mockResolvedValue([
{ id: 'theme', title: '/theme', type: 'command', data: { command: 'theme' } },
])
expect(slashAction.source).toBe('local')
if (slashAction.source !== 'local') throw new Error('Expected a local slash action')
const results = await slashAction.search('/theme dark', 'dark')
expect(mockSearch).toHaveBeenCalledWith('/theme dark', 'ja')
expect(results).toEqual([
{ id: 'theme', title: '/theme', type: 'command', data: { command: 'theme' } },
])
})
})
describe('SlashCommandProvider', () => {
beforeEach(() => {
vi.clearAllMocks()
// Default: feature preview off, so /create and /refine are NOT registered.
featureFlag.enabled = false
})
it('should not register the /create and /refine preview commands when the feature flag is off', () => {
const { unmount } = render(<SlashCommandProvider />)
expect(mockRegister.mock.calls.map((call) => call[0].name)).toEqual([
'theme',
'language',
'docs',
'discord',
'models',
'account',
'go',
])
expect(mockRegister).toHaveBeenCalledWith(expect.objectContaining({ name: 'theme' }), {
setTheme: mockSetTheme,
})
expect(mockRegister).toHaveBeenCalledWith(expect.objectContaining({ name: 'language' }), {
setLocale: mockSetLocale,
})
expect(mockRegister).toHaveBeenCalledWith(expect.objectContaining({ name: 'go' }), {
agentsAvailable: true,
skillsAvailable: true,
})
unmount()
// Unregister is always called for the preview commands (a no-op when they
// were never registered) so toggling the flag off mid-session stays clean.
expect(mockUnregister.mock.calls.map((call) => call[0])).toEqual([
'theme',
'language',
'docs',
'discord',
'models',
'account',
'go',
'create',
'refine',
])
})
it('should register the /create and /refine preview commands when the feature flag is on', () => {
featureFlag.enabled = true
const { unmount } = render(<SlashCommandProvider />)
expect(mockRegister.mock.calls.map((call) => call[0].name)).toEqual([
'theme',
'language',
'docs',
'discord',
'models',
'account',
'go',
'create',
'refine',
])
unmount()
expect(mockUnregister.mock.calls.map((call) => call[0])).toEqual([
'theme',
'language',
'docs',
'discord',
'models',
'account',
'go',
'create',
'refine',
])
})
it('should register the enterprise documentation home URL', () => {
const { unmount } = render(<SlashCommandProvider />, {
systemFeatures: { deployment_edition: 'ENTERPRISE' },
})
const docsRegistration = mockRegister.mock.calls.find((call) => call[0].name === 'docs')
expect(docsRegistration?.[1].getDocsHomeUrl()).toBe('https://enterprise-docs.dify.ai/en/')
unmount()
})
})
@@ -1,69 +0,0 @@
import { registerCommands, unregisterCommands } from '../command-bus'
import { themeCommand } from '../theme'
vi.mock('../command-bus')
describe('themeCommand', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('has correct metadata', () => {
expect(themeCommand.name).toBe('theme')
expect(themeCommand.mode).toBe('submenu')
expect(themeCommand.execute).toBeUndefined()
})
describe('search', () => {
it('returns all theme options when query is empty', async () => {
const results = await themeCommand.search('', 'en')
expect(results).toHaveLength(3)
expect(results.map((r) => r.id)).toEqual(['system', 'light', 'dark'])
})
it('returns all theme options with correct type', async () => {
const results = await themeCommand.search('', 'en')
results.forEach((r) => {
expect(r.type).toBe('command')
expect(r.data).toEqual({
command: 'theme.set',
args: expect.objectContaining({ value: expect.any(String) }),
})
})
})
it('filters results by query matching id', async () => {
const results = await themeCommand.search('dark', 'en')
expect(results).toHaveLength(1)
expect(results[0]!.id).toBe('dark')
})
})
describe('register / unregister', () => {
it('registers theme.set command with deps', () => {
const deps = { setTheme: vi.fn() }
themeCommand.register?.(deps)
expect(registerCommands).toHaveBeenCalledWith({ 'theme.set': expect.any(Function) })
})
it('unregisters theme.set command', () => {
themeCommand.unregister?.()
expect(unregisterCommands).toHaveBeenCalledWith(['theme.set'])
})
it('registered handler calls setTheme', async () => {
const setTheme = vi.fn()
vi.mocked(registerCommands).mockImplementation((map) => {
map['theme.set']?.({ value: 'dark' })
})
themeCommand.register?.({ setTheme })
expect(setTheme).toHaveBeenCalledWith('dark')
})
})
})
@@ -1,32 +1,26 @@
import type { SlashCommandHandler } from './types'
import { getI18n } from 'react-i18next'
import { registerCommands, unregisterCommands } from './command-bus'
// Account command dependency types - no external dependencies needed
type AccountDeps = Record<string, never>
import type { SlashCommand } from './types'
/**
* Account command - Navigates to account page
*/
export const accountCommand: SlashCommandHandler<AccountDeps> = {
export const accountCommand: SlashCommand = {
name: 'account',
description: 'Navigate to account page',
mode: 'direct',
// Direct execution function
execute: () => {
window.location.href = '/account'
},
execute: (_args, context) => context.navigate('/account'),
search(args: string, locale: string = 'en') {
const i18n = getI18n()
search(args: string, context) {
return [
{
id: 'account',
title: i18n.t(($) => $['account.account'], { ns: 'accountSettings', lng: locale }),
description: i18n.t(($) => $['gotoAnything.actions.accountDesc'], {
title: context.t(($) => $['account.account'], {
ns: 'accountSettings',
lng: context.locale,
}),
description: context.t(($) => $['gotoAnything.actions.accountDesc'], {
ns: 'app',
lng: locale,
lng: context.locale,
}),
type: 'command' as const,
icon: (
@@ -34,21 +28,8 @@ export const accountCommand: SlashCommandHandler<AccountDeps> = {
<span aria-hidden className="i-ri-user-3-line size-4 text-text-tertiary" />
</div>
),
data: { command: 'navigation.account', args: {} },
data: { command: 'account', args: {} },
},
]
},
register(_deps: AccountDeps) {
registerCommands({
'navigation.account': async (_args) => {
// Navigate to account page
window.location.href = '/account'
},
})
},
unregister() {
unregisterCommands(['navigation.account'])
},
}
@@ -0,0 +1,22 @@
import { ENABLE_FEATURE_PREVIEW } from '@/config'
import { accountCommand } from './account'
import { createCommand } from './create'
import { discordCommand } from './discord'
import { docsCommand } from './docs'
import { goCommand } from './go'
import { languageCommand } from './language'
import { modelsCommand } from './models'
import { refineCommand } from './refine'
import { SlashCommandRegistry } from './registry'
import { themeCommand } from './theme'
export const slashCommandRegistry = new SlashCommandRegistry([
themeCommand,
languageCommand,
docsCommand,
discordCommand,
modelsCommand,
accountCommand,
goCommand,
...(ENABLE_FEATURE_PREVIEW ? [createCommand, refineCommand] : []),
])
@@ -1,25 +0,0 @@
type CommandHandler = (args?: Record<string, any>) => void | Promise<void>
const handlers = new Map<string, CommandHandler>()
const registerCommand = (name: string, handler: CommandHandler) => {
handlers.set(name, handler)
}
const unregisterCommand = (name: string) => {
handlers.delete(name)
}
export const executeCommand = async (name: string, args?: Record<string, any>) => {
const handler = handlers.get(name)
if (!handler) return
await handler(args)
}
export const registerCommands = (map: Record<string, CommandHandler>) => {
Object.entries(map).forEach(([name, handler]) => registerCommand(name, handler))
}
export const unregisterCommands = (names: string[]) => {
names.forEach(unregisterCommand)
}
@@ -1,10 +1,5 @@
import type { SlashCommandHandler } from './types'
import type { SlashCommand } from './types'
import type { WorkflowGeneratorMode } from '@/app/components/workflow/workflow-generator/types'
import { getI18n } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useWorkflowGeneratorStore } from '@/app/components/workflow/workflow-generator/store'
import { AppModeEnum } from '@/types/app'
import { registerCommands, unregisterCommands } from './command-bus'
type CreateOption = {
id: string
@@ -66,16 +61,33 @@ const OPTIONS = [
* through so the modal offers "Apply to current draft". Auto-mode always creates
* a new app since the planner may pick a different type than the open Studio.
*/
export const createCommand: SlashCommandHandler = {
export const createCommand: SlashCommand = {
name: 'create',
aliases: ['new', 'generate'],
description: getI18n().t(($) => $['gotoAnything.actions.createCategoryDesc'], { ns: 'app' }),
description: 'Create an app with AI',
mode: 'submenu',
execute(args, context) {
const option = OPTIONS.find(
(option) => option.mode === args.mode && option.auto === !!args.auto,
)
if (!option) return
const initialInstruction = typeof args.instruction === 'string' ? args.instruction : ''
const app = context.currentApp
if (!option.auto && app?.mode === option.mode) {
context.openGenerator({
mode: option.mode,
currentAppId: app.id,
currentAppMode: app.mode,
initialInstruction,
})
return
}
context.openGenerator({ mode: option.mode, autoMode: option.auto, initialInstruction })
},
search(args: string, locale?: string) {
const i18n = getI18n()
search(args: string, context) {
const tr = (key: (typeof OPTIONS)[number]['titleKey' | 'descKey']) =>
i18n.t(($) => $[key], { ns: 'app', lng: locale })
context.t(($) => $[key], { ns: 'app', lng: context.locale })
const renderIcon = (iconClassName: string) => (
<div className="flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg">
@@ -91,7 +103,7 @@ export const createCommand: SlashCommandHandler = {
description: instruction || tr(opt.descKey),
type: 'command' as const,
icon: renderIcon(opt.iconClassName),
data: { command: 'create.open', args: { mode: opt.mode, auto: !!opt.auto, instruction } },
data: { command: 'create', args: { mode: opt.mode, auto: !!opt.auto, instruction } },
})
const trimmed = args.trim()
@@ -116,45 +128,4 @@ export const createCommand: SlashCommandHandler = {
if (matched) return [toResult(matched, tokens.slice(1).join(' '))]
return OPTIONS.map((opt) => toResult(opt, trimmed))
},
register() {
registerCommands({
'create.open': async (args) => {
const mode: WorkflowGeneratorMode = (args?.mode ?? 'workflow') as WorkflowGeneratorMode
const autoMode = !!args?.auto
const initialInstruction = typeof args?.instruction === 'string' ? args.instruction : ''
// If a graph-based Studio app is open and its mode matches the picked
// mode, thread it through so the modal can offer "Apply to current
// draft". A mode mismatch (or no app open) falls back to new-app only,
// mirroring the precondition the modal uses for canApplyToCurrent.
// Auto-mode always creates a new app — the planner may resolve a type
// different from the open Studio, so applying to the current draft is
// unsafe.
const appDetail = useAppStore.getState().appDetail
const currentAppMode: WorkflowGeneratorMode | null =
appDetail?.mode === AppModeEnum.WORKFLOW
? 'workflow'
: appDetail?.mode === AppModeEnum.ADVANCED_CHAT
? 'advanced-chat'
: null
if (!autoMode && appDetail && currentAppMode === mode) {
useWorkflowGeneratorStore.getState().openGenerator({
mode,
currentAppId: appDetail.id,
currentAppMode,
initialInstruction,
})
return
}
useWorkflowGeneratorStore.getState().openGenerator({ mode, autoMode, initialInstruction })
},
})
},
unregister() {
unregisterCommands(['create.open'])
},
}
@@ -1,49 +1,32 @@
import type { SlashCommandHandler } from './types'
import { getI18n } from 'react-i18next'
import { registerCommands, unregisterCommands } from './command-bus'
type DiscordDeps = Record<string, never>
import type { SlashCommand } from './types'
const DISCORD_URL = 'https://discord.gg/5AEfbxcd9k'
const openDiscord = (url = DISCORD_URL) => {
window.open(url, '_blank', 'noopener,noreferrer')
}
export const discordCommand: SlashCommandHandler<DiscordDeps> = {
export const discordCommand: SlashCommand = {
name: 'discord',
description: 'Open Discord community',
mode: 'direct',
execute: () => openDiscord(),
execute: (_args, context) => context.openExternal(DISCORD_URL),
search(_args: string, locale: string = 'en') {
const i18n = getI18n()
search(args: string, context) {
return [
{
id: 'discord',
title: 'Discord',
description:
i18n.t(($) => $['gotoAnything.actions.discordDesc'], { ns: 'app', lng: locale }) ||
'Open Discord community',
context.t(($) => $['gotoAnything.actions.discordDesc'], {
ns: 'app',
lng: context.locale,
}) || 'Open Discord community',
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">
<span aria-hidden className="i-ri-discord-line size-4 text-text-tertiary" />
</div>
),
data: { command: 'navigation.discord', args: { url: DISCORD_URL } },
data: { command: 'discord', args: { url: DISCORD_URL } },
},
]
},
register(_deps: DiscordDeps) {
registerCommands({
'navigation.discord': async (args) => openDiscord(args?.url),
})
},
unregister() {
unregisterCommands(['navigation.discord'])
},
}
@@ -1,39 +1,22 @@
import type { SlashCommandHandler } from './types'
import { getI18n } from 'react-i18next'
import { registerCommands, unregisterCommands } from './command-bus'
type DocDeps = {
getDocsHomeUrl: () => string
}
let getDocsHomeUrl: (() => string) | undefined
const openDocsHome = () => {
const url = getDocsHomeUrl?.()
if (url) window.open(url, '_blank', 'noopener,noreferrer')
}
import type { SlashCommand } from './types'
/**
* Documentation command - Opens help documentation
*/
export const docsCommand: SlashCommandHandler<DocDeps> = {
export const docsCommand: SlashCommand = {
name: 'docs',
description: 'Open documentation',
mode: 'direct',
// Direct execution function
execute: () => {
openDocsHome()
},
execute: (_args, context) => context.openExternal(context.getDocsHomeUrl()),
search(args: string, locale: string = 'en') {
const i18n = getI18n()
search(args: string, context) {
return [
{
id: 'doc',
title: i18n.t(($) => $['userProfile.helpCenter'], { ns: 'common', lng: locale }),
title: context.t(($) => $['userProfile.helpCenter'], { ns: 'common', lng: context.locale }),
description:
i18n.t(($) => $['gotoAnything.actions.docDesc'], { ns: 'app', lng: locale }) ||
context.t(($) => $['gotoAnything.actions.docDesc'], { ns: 'app', lng: context.locale }) ||
'Open help documentation',
type: 'command' as const,
icon: (
@@ -41,22 +24,8 @@ export const docsCommand: SlashCommandHandler<DocDeps> = {
<span aria-hidden className="i-ri-book-open-line size-4 text-text-tertiary" />
</div>
),
data: { command: 'navigation.doc', args: {} },
data: { command: 'docs', args: {} },
},
]
},
register(deps: DocDeps) {
getDocsHomeUrl = deps.getDocsHomeUrl
registerCommands({
'navigation.doc': async (_args) => {
openDocsHome()
},
})
},
unregister() {
getDocsHomeUrl = undefined
unregisterCommands(['navigation.doc'])
},
}
@@ -1,5 +1,4 @@
import type { SlashCommandHandler } from './types'
import { registerCommands, unregisterCommands } from './command-bus'
import type { SlashCommand } from './types'
const NAV_ITEMS = [
{ id: 'apps', label: 'Apps', path: '/apps', iconClassName: 'i-ri-apps-2-line' },
@@ -24,31 +23,27 @@ const NAV_ITEMS = [
{ id: 'account', label: 'Account', path: '/account', iconClassName: 'i-ri-user-line' },
] as const
type GoDeps = {
agentsAvailable: boolean
skillsAvailable: boolean
}
let availability: GoDeps = {
agentsAvailable: false,
skillsAvailable: true,
}
/**
* Go command - Navigate to a top-level section of the app
*/
export const goCommand: SlashCommandHandler<GoDeps> = {
export const goCommand: SlashCommand = {
name: 'go',
aliases: ['navigate', 'nav'],
description: 'Navigate to a section',
mode: 'submenu',
execute(args, context) {
const item = NAV_ITEMS.find((item) => item.path === args.path)
if (!item) return
if ('availability' in item && !context[`${item.availability}Available`]) return
context.navigate(item.path)
},
search(args: string, _locale: string = 'en') {
search(args: string, context) {
const query = args.trim().toLowerCase()
const items = NAV_ITEMS.filter((item) => {
if ('availability' in item) {
if (item.availability === 'agents' && !availability.agentsAvailable) return false
if (item.availability === 'skills' && !availability.skillsAvailable) return false
if (item.availability === 'agents' && !context.agentsAvailable) return false
if (item.availability === 'skills' && !context.skillsAvailable) return false
}
return !query || item.id.includes(query) || item.label.toLowerCase().includes(query)
@@ -63,24 +58,7 @@ export const goCommand: SlashCommandHandler<GoDeps> = {
<span aria-hidden className={`${item.iconClassName} size-4 text-text-tertiary`} />
</div>
),
data: { command: 'navigation.go', args: { path: item.path } },
data: { command: 'go', args: { path: item.path } },
}))
},
register(deps: GoDeps) {
availability = deps
registerCommands({
'navigation.go': async (args) => {
if (args?.path) window.location.href = args.path
},
})
},
unregister() {
availability = {
agentsAvailable: false,
skillsAvailable: true,
}
unregisterCommands(['navigation.go'])
},
}
@@ -1,56 +1,40 @@
import type { CommandSearchResult } from '../types'
import type { SlashCommandHandler } from './types'
import { getI18n } from 'react-i18next'
import type { CommandContext, SlashCommand } from './types'
import { languages } from '@/i18n/language'
import { registerCommands, unregisterCommands } from './command-bus'
// Language dependency types
type LanguageDeps = {
setLocale?: (locale: string) => Promise<void>
}
const buildLanguageCommands = (query: string): CommandSearchResult[] => {
const buildLanguageCommands = (query: string, context: CommandContext): CommandSearchResult[] => {
const q = query.toLowerCase()
const list = languages.filter(
(item) =>
item.supported &&
(!q || item.name.toLowerCase().includes(q) || String(item.value).toLowerCase().includes(q)),
)
const i18n = getI18n()
return list.map((item) => ({
id: `lang-${item.value}`,
title: item.name,
description: i18n.t(($) => $['gotoAnything.actions.languageChangeDesc'], { ns: 'app' }),
description: context.t(($) => $['gotoAnything.actions.languageChangeDesc'], {
ns: 'app',
lng: context.locale,
}),
type: 'command' as const,
data: { command: 'i18n.set', args: { locale: item.value } },
data: { command: 'language', args: { locale: item.value } },
}))
}
/**
* Language command handler
* Integrates UI building, search, and registration logic
*/
export const languageCommand: SlashCommandHandler<LanguageDeps> = {
export const languageCommand: SlashCommand = {
name: 'language',
aliases: ['lang'],
description: 'Switch between different languages',
mode: 'submenu', // Explicitly set submenu mode
search(args: string, _locale: string = 'en') {
// Return language options directly, regardless of parameters
return buildLanguageCommands(args)
mode: 'submenu',
async execute(args, context) {
const language = languages.find((item) => item.supported && item.value === args.locale)
if (language) await context.setLocale(language.value)
},
register(deps: LanguageDeps) {
registerCommands({
'i18n.set': async (args) => {
const locale = args?.locale
if (locale) await deps.setLocale?.(locale)
},
})
},
unregister() {
unregisterCommands(['i18n.set'])
search(args: string, context) {
return buildLanguageCommands(args, context)
},
}
@@ -1,34 +1,25 @@
import type { SlashCommandHandler } from './types'
import { getI18n } from 'react-i18next'
import { registerCommands, unregisterCommands } from './command-bus'
type ModelsDeps = Record<string, never>
import type { SlashCommand } from './types'
export const SYSTEM_MODELS_PATH = '/integrations/model-provider?dialog=system-models'
const openSystemModels = () => {
window.location.href = SYSTEM_MODELS_PATH
}
export const modelsCommand: SlashCommandHandler<ModelsDeps> = {
export const modelsCommand: SlashCommand = {
name: 'models',
description: 'Configure default workspace models',
mode: 'direct',
execute: openSystemModels,
execute: (_args, context) => context.navigate(SYSTEM_MODELS_PATH),
search(_args: string, locale: string = 'en') {
const i18n = getI18n()
search(args: string, context) {
return [
{
id: 'models',
title: i18n.t(($) => $['modelProvider.systemModelSettings'], {
title: context.t(($) => $['modelProvider.systemModelSettings'], {
ns: 'modelProvider',
lng: locale,
lng: context.locale,
}),
description: i18n.t(($) => $['modelProvider.systemModelSettingsDesc'], {
description: context.t(($) => $['modelProvider.systemModelSettingsDesc'], {
ns: 'modelProvider',
lng: locale,
lng: context.locale,
}),
type: 'command' as const,
icon: (
@@ -36,18 +27,8 @@ export const modelsCommand: SlashCommandHandler<ModelsDeps> = {
<span aria-hidden className="i-ri-brain-2-line size-4 text-text-tertiary" />
</div>
),
data: { command: 'navigation.models' },
data: { command: 'models' },
},
]
},
register(_deps: ModelsDeps) {
registerCommands({
'navigation.models': async () => openSystemModels(),
})
},
unregister() {
unregisterCommands(['navigation.models'])
},
}
@@ -1,70 +1,37 @@
import type { SlashCommandHandler } from './types'
import type { WorkflowGeneratorMode } from '@/app/components/workflow/workflow-generator/types'
import { getI18n } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useWorkflowGeneratorStore } from '@/app/components/workflow/workflow-generator/store'
import { AppModeEnum } from '@/types/app'
import { registerCommands, unregisterCommands } from './command-bus'
import type { SlashCommand } from './types'
/**
* Map the open app's mode to a generator mode, or null when the current app
* isn't a graph-based Studio (Chat / Agent / Completion have no canvas to
* refine). This is the single source of truth for both the availability gate
* and the mode we open the generator with.
*/
const currentStudioMode = (): WorkflowGeneratorMode | null => {
const appMode = useAppStore.getState().appDetail?.mode
if (appMode === AppModeEnum.WORKFLOW) return 'workflow'
if (appMode === AppModeEnum.ADVANCED_CHAT) return 'advanced-chat'
return null
}
/**
* Open the generator in `refine` intent for the current Studio. The modal
* fetches the current draft graph and sends it as context so the LLM amends
* what's on the canvas instead of starting from scratch. No-op when there's
* no graph-based app open (the command is gated by `isAvailable`, but the
* guard keeps a stray command-bus call safe).
*/
const openRefineGenerator = () => {
const appDetail = useAppStore.getState().appDetail
const mode = currentStudioMode()
if (!appDetail || !mode) return
useWorkflowGeneratorStore.getState().openGenerator({
intent: 'refine',
mode,
currentAppId: appDetail.id,
currentAppMode: mode,
})
}
/**
* `/refine` command — refine the CURRENT Workflow / Chatflow draft graph from
* a natural-language change description. Only available inside a graph-based
* Studio; the mode is taken from the open app (no submenu to pick), and the
* result always applies back to the current draft.
*/
export const refineCommand: SlashCommandHandler = {
export const refineCommand: SlashCommand = {
name: 'refine',
aliases: ['improve'],
description: getI18n().t(($) => $['gotoAnything.actions.refineCategoryDesc'], { ns: 'app' }),
description: 'Refine the current app',
mode: 'direct',
// Only surface inside a Workflow / Advanced-Chat Studio — elsewhere there's
// no draft graph to refine.
isAvailable: () => currentStudioMode() !== null,
isAvailable: (context) => context.currentApp !== null,
execute: openRefineGenerator,
execute(_args, context) {
const app = context.currentApp
if (!app) return
context.openGenerator({
intent: 'refine',
mode: app.mode,
currentAppId: app.id,
currentAppMode: app.mode,
})
},
search(_args: string, locale?: string) {
const i18n = getI18n()
search(_args: string, context) {
return [
{
id: 'refine-current',
title: i18n.t(($) => $['gotoAnything.actions.refineTitle'], { ns: 'app', lng: locale }),
description: i18n.t(($) => $['gotoAnything.actions.refineDesc'], {
title: context.t(($) => $['gotoAnything.actions.refineTitle'], {
ns: 'app',
lng: locale,
lng: context.locale,
}),
description: context.t(($) => $['gotoAnything.actions.refineDesc'], {
ns: 'app',
lng: context.locale,
}),
type: 'command' as const,
icon: (
@@ -72,18 +39,8 @@ export const refineCommand: SlashCommandHandler = {
<span aria-hidden className="i-ri-sparkling-2-line size-4 text-text-tertiary" />
</div>
),
data: { command: 'refine.open', args: {} },
data: { command: 'refine', args: {} },
},
]
},
register() {
registerCommands({
'refine.open': async () => openRefineGenerator(),
})
},
unregister() {
unregisterCommands(['refine.open'])
},
}
@@ -1,256 +1,74 @@
import type { CommandSearchResult } from '../types'
import type { SlashCommand, SlashCommandHandler } from './types'
import type { CommandContext, SlashCommand } from './types'
/**
* Slash Command Registry System
* Responsible for managing registration, lookup, and search of all slash commands
*/
export class SlashCommandRegistry {
private commands = new Map<string, SlashCommand>()
private snapshot: SlashCommand[] = []
private listeners = new Set<() => void>()
private readonly commands: readonly SlashCommand[]
private readonly byName = new Map<string, SlashCommand>()
getSnapshot = () => this.snapshot
subscribe = (listener: () => void) => {
this.listeners.add(listener)
return () => {
this.listeners.delete(listener)
}
}
private notify() {
this.snapshot = this.getAllCommands()
this.listeners.forEach((listener) => listener())
}
private commandDeps = new Map<string, unknown>()
/**
* Register command handler
*/
register<TDeps>(handler: SlashCommandHandler<TDeps>, deps?: TDeps) {
// Register main command name
this.commands.set(handler.name, handler)
// Register aliases
if (handler.aliases) {
handler.aliases.forEach((alias) => {
this.commands.set(alias, handler)
})
}
// Store dependencies and call registration method
if (deps) {
this.commandDeps.set(handler.name, deps)
handler.register?.(deps)
}
this.notify()
}
/**
* Unregister command
*/
unregister(name: string) {
const handler = this.commands.get(name)
if (handler) {
// Call the command's unregister method
handler.unregister?.()
// Remove dependencies
this.commandDeps.delete(handler.name)
// Remove main command name
this.commands.delete(handler.name)
// Remove all aliases
if (handler.aliases) {
handler.aliases.forEach((alias) => {
this.commands.delete(alias)
})
constructor(commands: readonly SlashCommand[]) {
this.commands = [...commands]
for (const command of commands) {
for (const name of [command.name, ...(command.aliases ?? [])]) {
if (this.byName.has(name)) throw new Error(`Duplicate slash command: ${name}`)
this.byName.set(name, command)
}
this.notify()
}
}
/**
* Find command handler
*/
findCommand(commandName: string): SlashCommand | undefined {
return this.commands.get(commandName)
findCommand(name: string) {
return this.byName.get(name)
}
/**
* Smart partial command matching
* Prioritize alias matching, then match command name prefix
*/
private findBestPartialMatch(partialName: string): SlashCommand | undefined {
const lowerPartial = partialName.toLowerCase()
// First check if any alias starts with this
const aliasMatch = this.findHandlerByAliasPrefix(lowerPartial)
if (aliasMatch && this.isCommandAvailable(aliasMatch)) return aliasMatch
// Then check if command name starts with this
const nameMatch = this.findHandlerByNamePrefix(lowerPartial)
return nameMatch && this.isCommandAvailable(nameMatch) ? nameMatch : undefined
getAvailableCommands(context: CommandContext) {
return this.commands.filter((command) => command.isAvailable?.(context) ?? true)
}
/**
* Find handler by alias prefix
*/
private findHandlerByAliasPrefix(prefix: string): SlashCommand | undefined {
for (const handler of this.getAllCommands()) {
if (handler.aliases?.some((alias) => alias.toLowerCase().startsWith(prefix))) return handler
async execute(name: string, args: Record<string, unknown>, context: CommandContext) {
const command = this.findCommand(name)
if (!command || command.isAvailable?.(context) === false) return
try {
await command.execute(args, context)
} catch (error) {
context.onError(error)
}
return undefined
}
/**
* Find handler by name prefix
*/
private findHandlerByNamePrefix(prefix: string): SlashCommand | undefined {
return this.getAllCommands().find((handler) => handler.name.toLowerCase().startsWith(prefix))
}
search(query: string, context: CommandContext): CommandSearchResult[] {
const input = query.trim().replace(/^\//, '').trim()
const commands = this.getAvailableCommands(context)
if (!input) return commands.map((command) => this.toResult(command))
/**
* Get all registered commands (deduplicated)
*/
getAllCommands(): SlashCommand[] {
const uniqueCommands = new Map<string, SlashCommand>()
this.commands.forEach((handler) => {
uniqueCommands.set(handler.name, handler)
})
return Array.from(uniqueCommands.values())
}
/**
* Get all available commands in current context (deduplicated and filtered)
* Commands without isAvailable method are considered always available
*/
getAvailableCommands(): SlashCommand[] {
return this.getAllCommands().filter((handler) => this.isCommandAvailable(handler))
}
/**
* Search commands
* @param query Full query (e.g., "/theme dark" or "/lang en")
* @param locale Current language
*/
search(query: string, locale: string = 'en'): CommandSearchResult[] {
const trimmed = query.trim()
// Handle root level search "/"
if (trimmed === '/' || !trimmed.replace('/', '').trim()) return this.getRootCommands()
// Parse command and arguments
const afterSlash = trimmed.substring(1).trim()
const spaceIndex = afterSlash.indexOf(' ')
const commandName = spaceIndex === -1 ? afterSlash : afterSlash.substring(0, spaceIndex)
const args = spaceIndex === -1 ? '' : afterSlash.substring(spaceIndex + 1).trim()
// First try exact match
let handler = this.findCommand(commandName)
if (handler && this.isCommandAvailable(handler)) {
const name = input.split(/\s/, 1)[0]!.toLowerCase()
const args = input.slice(name.length).trim()
const exact = this.findCommand(name)
if (exact?.isAvailable?.(context) === false) return []
const command =
exact ??
commands.find((candidate) => candidate.aliases?.some((alias) => alias.startsWith(name))) ??
commands.find((candidate) => candidate.name.startsWith(name))
if (command) {
try {
return handler.search(args, locale)
return command.search(args, context)
} catch (error) {
console.warn(`Command search failed for ${commandName}:`, error)
console.warn(`Command search failed for ${command.name}:`, error)
return []
}
}
// If no exact match, try smart partial matching
handler = this.findBestPartialMatch(commandName)
if (handler && this.isCommandAvailable(handler)) {
try {
return handler.search(args, locale)
} catch (error) {
console.warn(`Command search failed for ${handler.name}:`, error)
return []
}
return commands.flatMap((candidate) =>
[candidate.name, ...(candidate.aliases ?? [])]
.filter((name) => name.toLowerCase().includes(input.toLowerCase()))
.map((name) => this.toResult(candidate, name)),
)
}
private toResult(command: SlashCommand, name = command.name): CommandSearchResult {
return {
id: `root-${name}`,
title: `/${name}`,
description: command.description,
type: 'command',
data: { command: command.name },
}
// Finally perform fuzzy search
return this.fuzzySearchCommands(afterSlash)
}
/**
* Get root level command list
* Only shows commands that are available in current context
*/
private getRootCommands(): CommandSearchResult[] {
return this.getAvailableCommands().map((handler) => ({
id: `root-${handler.name}`,
title: `/${handler.name}`,
description: handler.description,
type: 'command' as const,
data: {
command: `root.${handler.name}`,
args: { name: handler.name },
},
}))
}
/**
* Fuzzy search commands
* Only shows commands that are available in current context
*/
private fuzzySearchCommands(query: string): CommandSearchResult[] {
const lowercaseQuery = query.toLowerCase()
const matches: CommandSearchResult[] = []
for (const handler of this.getAvailableCommands()) {
// Check if command name matches
if (handler.name.toLowerCase().includes(lowercaseQuery)) {
matches.push({
id: `fuzzy-${handler.name}`,
title: `/${handler.name}`,
description: handler.description,
type: 'command' as const,
data: {
command: `root.${handler.name}`,
args: { name: handler.name },
},
})
}
// Check if aliases match
if (handler.aliases) {
handler.aliases.forEach((alias) => {
if (alias.toLowerCase().includes(lowercaseQuery)) {
matches.push({
id: `fuzzy-${alias}`,
title: `/${alias}`,
description: `${handler.description} (alias for /${handler.name})`,
type: 'command' as const,
data: {
command: `root.${handler.name}`,
args: { name: handler.name },
},
})
}
})
}
}
return matches
}
/**
* Get command dependencies
*/
getCommandDependencies(commandName: string): unknown {
return this.commandDeps.get(commandName)
}
/**
* Determine if a command is available in the current context.
* Defaults to true when a handler does not implement the guard.
*/
private isCommandAvailable(handler: SlashCommand) {
return handler.isAvailable?.() ?? true
}
}
// Global registry instance
export const slashCommandRegistry = new SlashCommandRegistry()
@@ -1,78 +0,0 @@
'use client'
import { useAtomValue } from 'jotai'
import { useTheme } from 'next-themes'
import { useEffect } from 'react'
import { ENABLE_FEATURE_PREVIEW } from '@/config'
import { useDocLink } from '@/context/i18n'
import { isCurrentWorkspaceDatasetOperatorAtom } from '@/context/workspace-state'
import { isAgentV2Enabled } from '@/features/agent-v2/feature-flag'
import { setLocaleOnClient } from '@/i18n/client'
import { accountCommand } from './account'
import { createCommand } from './create'
import { discordCommand } from './discord'
import { docsCommand } from './docs'
import { goCommand } from './go'
import { languageCommand } from './language'
import { modelsCommand } from './models'
import { refineCommand } from './refine'
import { slashCommandRegistry } from './registry'
import { themeCommand } from './theme'
type SlashCommandDeps = {
agentsAvailable: boolean
getDocsHomeUrl: () => string
setTheme: (theme: string) => void
setLocale: typeof setLocaleOnClient
skillsAvailable: boolean
}
const registerSlashCommands = (deps: SlashCommandDeps) => {
slashCommandRegistry.register(themeCommand, { setTheme: deps.setTheme })
slashCommandRegistry.register(languageCommand, {
setLocale: deps.setLocale as (locale: string) => Promise<void>,
})
slashCommandRegistry.register(docsCommand, { getDocsHomeUrl: deps.getDocsHomeUrl })
slashCommandRegistry.register(discordCommand, {})
slashCommandRegistry.register(modelsCommand, {})
slashCommandRegistry.register(accountCommand, {})
slashCommandRegistry.register(goCommand, {
agentsAvailable: deps.agentsAvailable,
skillsAvailable: deps.skillsAvailable,
})
if (ENABLE_FEATURE_PREVIEW) {
slashCommandRegistry.register(createCommand, {})
slashCommandRegistry.register(refineCommand, {})
}
}
const unregisterSlashCommands = () => {
slashCommandRegistry.unregister('theme')
slashCommandRegistry.unregister('language')
slashCommandRegistry.unregister('docs')
slashCommandRegistry.unregister('discord')
slashCommandRegistry.unregister('models')
slashCommandRegistry.unregister('account')
slashCommandRegistry.unregister('go')
slashCommandRegistry.unregister('create')
slashCommandRegistry.unregister('refine')
}
export const SlashCommandProvider = () => {
const theme = useTheme()
const getDocsHomeUrl = useDocLink()
const isCurrentWorkspaceDatasetOperator = useAtomValue(isCurrentWorkspaceDatasetOperatorAtom)
const agentsAvailable = isAgentV2Enabled()
const skillsAvailable = !isCurrentWorkspaceDatasetOperator
useEffect(() => {
registerSlashCommands({
agentsAvailable,
getDocsHomeUrl,
setTheme: theme.setTheme,
setLocale: setLocaleOnClient,
skillsAvailable,
})
return () => unregisterSlashCommands()
}, [agentsAvailable, getDocsHomeUrl, skillsAvailable, theme.setTheme])
return null
}
@@ -1,28 +1,28 @@
import type { ActionItem } from '../types'
import { getI18n } from 'react-i18next'
import { executeCommand } from './command-bus'
import { slashCommandRegistry } from './registry'
import type { CommandContext } from './types'
import { slashCommandRegistry } from './catalog'
export const slashAction: ActionItem = {
key: '/',
shortcut: '/',
get title() {
const i18n = getI18n()
return i18n.t(($) => $['gotoAnything.actions.slashTitle'], { ns: 'app' })
},
get description() {
const i18n = getI18n()
return i18n.t(($) => $['gotoAnything.actions.slashDesc'], { ns: 'app' })
},
source: 'local',
action: (result) => {
if (result.type !== 'command') return
const { command, args } = result.data
executeCommand(command, args)
},
search: (query, _searchTerm = '') => {
const i18n = getI18n()
// Delegate all search logic to the command registry system
return slashCommandRegistry.search(query, i18n.language)
},
export function createSlashAction(context: CommandContext): ActionItem {
return {
key: '/',
shortcut: '/',
title: context.t(($) => $['gotoAnything.actions.slashTitle'], { ns: 'app' }),
description: context.t(($) => $['gotoAnything.actions.slashDesc'], { ns: 'app' }),
source: 'local',
matches: (query) =>
slashCommandRegistry
.getAvailableCommands(context)
.some(
(command) =>
command.mode !== 'direct' &&
[command.name, ...(command.aliases ?? [])].some((name) =>
query.startsWith(`/${name} `),
),
),
action: (result) => {
if (result.type === 'command')
void slashCommandRegistry.execute(result.data.command, result.data.args ?? {}, context)
},
search: (query) => slashCommandRegistry.search(query, context),
}
}
@@ -1,12 +1,5 @@
import type { CommandSearchResult } from '../types'
import type { SlashCommandHandler } from './types'
import { getI18n } from 'react-i18next'
import { registerCommands, unregisterCommands } from './command-bus'
// Theme dependency types
type ThemeDeps = {
setTheme?: (value: 'light' | 'dark' | 'system') => void
}
import type { CommandContext, SlashCommand } from './types'
const THEME_ITEMS = [
{
@@ -29,55 +22,44 @@ const THEME_ITEMS = [
},
] as const
const buildThemeCommands = (query: string, locale?: string): CommandSearchResult[] => {
const i18n = getI18n()
const buildThemeCommands = (query: string, context: CommandContext): CommandSearchResult[] => {
const q = query.toLowerCase()
const list = THEME_ITEMS.filter(
(item) =>
!q ||
i18n
.t(($) => $[item.titleKey], { ns: 'app', lng: locale })
context
.t(($) => $[item.titleKey], { ns: 'app', lng: context.locale })
.toLowerCase()
.includes(q) ||
item.id.includes(q),
)
return list.map((item) => ({
id: item.id,
title: i18n.t(($) => $[item.titleKey], { ns: 'app', lng: locale }),
description: i18n.t(($) => $[item.descKey], { ns: 'app', lng: locale }),
title: context.t(($) => $[item.titleKey], { ns: 'app', lng: context.locale }),
description: context.t(($) => $[item.descKey], { ns: 'app', lng: context.locale }),
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">
<span aria-hidden className={`${item.iconClassName} size-4 text-text-tertiary`} />
</div>
),
data: { command: 'theme.set', args: { value: item.id } },
data: { command: 'theme', args: { value: item.id } },
}))
}
/**
* Theme command handler
* Integrates UI building, search, and registration logic
*/
export const themeCommand: SlashCommandHandler<ThemeDeps> = {
export const themeCommand: SlashCommand = {
name: 'theme',
description: 'Switch between light and dark themes',
mode: 'submenu', // Explicitly set submenu mode
search(args: string, locale: string = 'en') {
// Return theme options directly, regardless of parameters
return buildThemeCommands(args, locale)
mode: 'submenu',
execute(args, context) {
const item = THEME_ITEMS.find((item) => item.id === args.value)
if (item) context.setTheme(item.id)
},
register(deps: ThemeDeps) {
registerCommands({
'theme.set': async (args) => {
deps.setTheme?.(args?.value)
},
})
},
unregister() {
unregisterCommands(['theme.set'])
search(args: string, context) {
return buildThemeCommands(args, context)
},
}
@@ -1,55 +1,28 @@
import type { TFunction } from 'i18next'
import type { CommandSearchResult } from '../types'
import type { useWorkflowGeneratorStore } from '@/app/components/workflow/workflow-generator/store'
/**
* Slash command handler interface
* Each slash command should implement this interface
*/
export type SlashCommandHandler<TDeps = unknown> = {
/** Command name (e.g., 'theme', 'language') */
name: string
/** Command alias list (e.g., ['lang'] for language) */
aliases?: string[]
/** Command description */
description: string
/**
* Command mode:
* - 'direct': Execute immediately when selected (e.g., /docs, /discord)
* - 'submenu': Show submenu options (e.g., /theme, /language)
*/
mode?: 'direct' | 'submenu'
/**
* Check if command is available in current context
* If not implemented, command is always available
* Used to conditionally show/hide commands based on page, user state, etc.
*/
isAvailable?: () => boolean
/**
* Direct execution function for 'direct' mode commands
* Called when the command is selected and should execute immediately
*/
execute?: () => void | Promise<void>
/**
* Search command results (for 'submenu' mode or showing options)
* @param args Command arguments (part after removing command name)
* @param locale Current language
*/
search: (args: string, locale?: string) => CommandSearchResult[]
/**
* Called when registering command, passing external dependencies
*/
register?: (deps: TDeps) => void
/**
* Called when unregistering command
*/
unregister?: () => void
export type CommandContext = {
t: TFunction<['app', 'common', 'modelProvider', 'accountSettings']>
locale: string
agentsAvailable: boolean
skillsAvailable: boolean
currentApp: { id: string; mode: 'workflow' | 'advanced-chat' } | null
setTheme: (theme: string) => void
setLocale: (locale: string) => Promise<void>
getDocsHomeUrl: () => string
onError: (error: unknown) => void
navigate: (path: string) => void
openExternal: (url: string) => void
openGenerator: ReturnType<typeof useWorkflowGeneratorStore.getState>['openGenerator']
}
export type SlashCommand = Omit<SlashCommandHandler, 'register'>
export type SlashCommand = {
name: string
aliases?: readonly string[]
description: string
mode?: 'direct' | 'submenu'
isAvailable?: (context: CommandContext) => boolean
execute: (args: Record<string, unknown>, context: CommandContext) => void | Promise<void>
search: (args: string, context: CommandContext) => CommandSearchResult[]
}
@@ -0,0 +1,64 @@
import type { CommandContext } from './types'
import { useTheme } from 'next-themes'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useWorkflowGeneratorStore } from '@/app/components/workflow/workflow-generator/store'
import { toast } from '@/app/notifications'
import { useDocLink } from '@/context/i18n'
import { setLocaleOnClient } from '@/i18n/client'
import { useRouter } from '@/next/navigation'
const openExternal = (url: string) => {
window.open(url, '_blank', 'noopener,noreferrer')
}
const openGenerator: CommandContext['openGenerator'] = (options) =>
useWorkflowGeneratorStore.getState().openGenerator(options)
export function useCommandContext(
agentsAvailable: boolean,
skillsAvailable: boolean,
isWorkflowPage: boolean,
): CommandContext {
const { t, i18n } = useTranslation(['app', 'common', 'modelProvider', 'accountSettings'])
const { setTheme } = useTheme()
const { push } = useRouter()
const getDocsHomeUrl = useDocLink()
const appId = useAppStore((state) => state.appDetail?.id)
const appMode = useAppStore((state) => state.appDetail?.mode)
return useMemo<CommandContext>(
() => ({
t,
locale: i18n.language,
agentsAvailable,
skillsAvailable,
currentApp:
isWorkflowPage && appId && (appMode === 'workflow' || appMode === 'advanced-chat')
? { id: appId, mode: appMode }
: null,
setTheme,
setLocale: setLocaleOnClient,
getDocsHomeUrl,
navigate: push,
onError: (error) => {
console.error('Goto Anything command failed:', error)
toast.error(t(($) => $['api.actionFailed'], { ns: 'common' }))
},
openExternal,
openGenerator,
}),
[
t,
i18n.language,
agentsAvailable,
skillsAvailable,
isWorkflowPage,
appId,
appMode,
setTheme,
getDocsHomeUrl,
push,
],
)
}
@@ -1,8 +1,6 @@
import type { ActionItem } from './types'
import { agentAction } from './agent'
import { appAction } from './app'
import { slashCommandRegistry } from './commands/registry'
import { slashAction } from './commands/slash'
import { knowledgeAction } from './knowledge'
import { pluginAction } from './plugin'
import { ragPipelineNodesAction } from './rag-pipeline-nodes'
@@ -10,7 +8,6 @@ import { skillAction } from './skill'
import { workflowNodesAction } from './workflow-nodes'
const defaultActions = {
slash: slashAction,
app: appAction,
knowledge: knowledgeAction,
plugin: pluginAction,
@@ -27,12 +24,14 @@ const defaultAvailability: ActionAvailability = {
}
export function createActions(
slash: ActionItem,
isWorkflowPage: boolean,
isRagPipelinePage: boolean,
availability: ActionAvailability = defaultAvailability,
) {
const availableActions = {
...defaultActions,
slash,
...(availability.skills ? { skill: skillAction } : {}),
...(availability.agents ? { agent: agentAction } : {}),
}
@@ -52,15 +51,7 @@ export function getActionSearchTerm(query: string, action: ActionItem) {
export function matchAction(query: string, actions: Record<string, ActionItem>) {
return Object.values(actions).find((action) => {
if (action.key === '/') {
return slashCommandRegistry.getAvailableCommands().some((command) => {
if (command.mode === 'direct') return false
return [command.name, ...(command.aliases ?? [])].some((name) =>
query.startsWith(`/${name} `),
)
})
}
if (action.matches) return action.matches(query)
return new RegExp(`^(${action.key}|${action.shortcut})\\s`).test(query)
})
@@ -73,6 +73,7 @@ type ActionItemBase = {
shortcut: string
title: string | TypeWithI18N
description: string
matches?: (query: string) => boolean
action?: (data: SearchResult) => void
}
@@ -1,8 +1,8 @@
import type { CommandContext } from './actions/commands/types'
import type { ActionItem, CommandSearchResult, SearchResult } from './actions/types'
import { useSyncExternalStore } from 'react'
import { useTranslation } from 'react-i18next'
import { MAIN_NAV_ROUTES } from '@/app/components/main-nav/routes'
import { slashCommandRegistry } from './actions/commands/registry'
import { slashCommandRegistry } from './actions/commands/catalog'
export type CommandOption = {
kind: 'command-option'
@@ -55,14 +55,13 @@ function matches(query: string, values: (string | undefined)[]) {
.every((term) => text.includes(term))
}
export function useCommandOptions(actions: Record<string, ActionItem>, query: string) {
const { t, i18n } = useTranslation(['app', 'common', 'skill', 'modelProvider', 'agentRoster'])
const registeredCommands = useSyncExternalStore(
slashCommandRegistry.subscribe,
slashCommandRegistry.getSnapshot,
slashCommandRegistry.getSnapshot,
)
const commands = registeredCommands.filter((command) => command.isAvailable?.() ?? true)
export function useCommandOptions(
actions: Record<string, ActionItem>,
query: string,
context: CommandContext,
) {
const { t } = useTranslation(['app', 'common', 'skill', 'modelProvider', 'agentRoster'])
const commands = slashCommandRegistry.getAvailableCommands(context)
const trimmed = query.trim()
const filter = trimmed.replace(/^[@/]/, '')
@@ -115,7 +114,7 @@ export function useCommandOptions(actions: Record<string, ActionItem>, query: st
if (trimmed.startsWith('/') || command.mode === 'direct') return []
return slashCommandRegistry
.search(`/${command.name}`, i18n.language)
.search(`/${command.name}`, context)
.filter((result) => matches(filter, [result.title, result.description, result.id]))
.map((result) => ({
...option,
+10 -9
View File
@@ -44,8 +44,9 @@ import InstallFromMarketplace from '../plugins/install-plugin/install-from-marke
import { createActions, getActionSearchTerm, matchAction } from './actions'
import { agentSearchQueryOptions } from './actions/agent'
import { appSearchQueryOptions } from './actions/app'
import { slashCommandRegistry } from './actions/commands/registry'
import { SlashCommandProvider } from './actions/commands/slash-provider'
import { slashCommandRegistry } from './actions/commands/catalog'
import { createSlashAction } from './actions/commands/slash'
import { useCommandContext } from './actions/commands/use-command-context'
import { knowledgeSearchQueryOptions } from './actions/knowledge'
import { pluginSearchQueryOptions } from './actions/plugin'
import { skillSearchQueryOptions } from './actions/skill'
@@ -161,15 +162,16 @@ export function GotoAnything() {
const [activePlugin, setActivePlugin] = useState<Plugin>()
const inputRef = useRef<HTMLInputElement>(null)
const searchHintId = useId()
const commandContext = useCommandContext(agentsAvailable, skillsAvailable, isWorkflowPage)
const actions = useMemo(
() =>
createActions(isWorkflowPage, isRagPipelinePage, {
createActions(createSlashAction(commandContext), isWorkflowPage, isRagPipelinePage, {
agents: agentsAvailable,
skills: skillsAvailable,
}),
[agentsAvailable, isWorkflowPage, isRagPipelinePage, skillsAvailable],
[agentsAvailable, commandContext, isWorkflowPage, isRagPipelinePage, skillsAvailable],
)
const { commandOptions, scopeOptions } = useCommandOptions(actions, searchQuery)
const { commandOptions, scopeOptions } = useCommandOptions(actions, searchQuery, commandContext)
const trimmedSearchQuery = searchQuery.trim()
const normalizedSearchQuery = searchQuery.trimStart().toLowerCase()
const isCommandsMode = isCommandSelectionQuery(searchQuery, actions)
@@ -325,9 +327,9 @@ export function GotoAnything() {
function handleCommandSelect(commandKey: string) {
if (commandKey.startsWith('/')) {
const handler = slashCommandRegistry.findCommand(commandKey.slice(1))
if (handler?.isAvailable?.() === false) return
if (handler?.mode === 'direct' && handler.execute) {
handler.execute()
if (handler?.isAvailable?.(commandContext) === false) return
if (handler?.mode === 'direct') {
void slashCommandRegistry.execute(handler.name, {}, commandContext)
gotoAnythingDialogHandle.close()
return
}
@@ -431,7 +433,6 @@ export function GotoAnything() {
return (
<>
<SlashCommandProvider />
<Dialog
handle={gotoAnythingDialogHandle}
onOpenChangeComplete={handleDialogOpenChangeComplete}
-1
View File
@@ -68,7 +68,6 @@
"gotoAnything.actions.docDesc": "فتح الوثائق",
"gotoAnything.actions.goDesc": "الانتقال إلى صفحة",
"gotoAnything.actions.languageChangeDesc": "تغيير اللغة",
"gotoAnything.actions.refineCategoryDesc": "قم بتحسين سير العمل الحالي أو الرسم البياني لتدفق الدردشة",
"gotoAnything.actions.refineDesc": "قم بوصف التغيير الذي سيتم تطبيقه على المسودة الحالية",
"gotoAnything.actions.refineTitle": "تحسين الرسم البياني الحالي",
"gotoAnything.actions.searchApplications": "بحث في التطبيقات",
-1
View File
@@ -68,7 +68,6 @@
"gotoAnything.actions.docDesc": "Sənədləri açın",
"gotoAnything.actions.goDesc": "Səhifəyə keç",
"gotoAnything.actions.languageChangeDesc": "Dili dəyişin",
"gotoAnything.actions.refineCategoryDesc": "Cari iş axını və ya söhbət axını qrafını təkmilləşdir",
"gotoAnything.actions.refineDesc": "Cari qaralamaya tətbiq ediləcək dəyişikliyi təsvir et",
"gotoAnything.actions.refineTitle": "Cari qrafı təkmilləşdir",
"gotoAnything.actions.searchApplications": "Tətbiqləri axtar",
-1
View File
@@ -68,7 +68,6 @@
"gotoAnything.actions.docDesc": "Dokumentation öffnen",
"gotoAnything.actions.goDesc": "Zu einer Seite wechseln",
"gotoAnything.actions.languageChangeDesc": "Sprache ändern",
"gotoAnything.actions.refineCategoryDesc": "Verfeinern Sie den aktuellen Workflow oder das Chatflow-Diagramm",
"gotoAnything.actions.refineDesc": "Beschreiben Sie eine Änderung, die auf den aktuellen Entwurf angewendet werden soll",
"gotoAnything.actions.refineTitle": "Aktuelles Diagramm verfeinern",
"gotoAnything.actions.searchApplications": "Anwendungen durchsuchen",
-1
View File
@@ -68,7 +68,6 @@
"gotoAnything.actions.docDesc": "Open documentation",
"gotoAnything.actions.goDesc": "Go to a page",
"gotoAnything.actions.languageChangeDesc": "Change language",
"gotoAnything.actions.refineCategoryDesc": "Refine the current workflow or chatflow graph",
"gotoAnything.actions.refineDesc": "Describe a change to apply to the current draft",
"gotoAnything.actions.refineTitle": "Refine current graph",
"gotoAnything.actions.searchApplications": "Search Applications",
-1
View File
@@ -68,7 +68,6 @@
"gotoAnything.actions.docDesc": "Abrir documentación",
"gotoAnything.actions.goDesc": "Ir a una página",
"gotoAnything.actions.languageChangeDesc": "Cambiar idioma",
"gotoAnything.actions.refineCategoryDesc": "Refinar el flujo de trabajo actual o el gráfico de flujo de chat",
"gotoAnything.actions.refineDesc": "Describir un cambio para aplicar al borrador actual.",
"gotoAnything.actions.refineTitle": "Refinar el gráfico actual",
"gotoAnything.actions.searchApplications": "Buscar aplicaciones",
-1
View File
@@ -68,7 +68,6 @@
"gotoAnything.actions.docDesc": "باز کردن مستندات",
"gotoAnything.actions.goDesc": "رفتن به یک صفحه",
"gotoAnything.actions.languageChangeDesc": "تغییر زبان",
"gotoAnything.actions.refineCategoryDesc": "جریان کار یا نمودار جریان گفتگو را اصلاح کنید",
"gotoAnything.actions.refineDesc": "تغییری را برای اعمال در پیش‌نویس فعلی توضیح دهید",
"gotoAnything.actions.refineTitle": "نمودار فعلی را اصلاح کنید",
"gotoAnything.actions.searchApplications": "جستجوی برنامه ها",
-1
View File
@@ -68,7 +68,6 @@
"gotoAnything.actions.docDesc": "Ouvrir la documentation",
"gotoAnything.actions.goDesc": "Accéder à une page",
"gotoAnything.actions.languageChangeDesc": "Changer de langue",
"gotoAnything.actions.refineCategoryDesc": "Affiner le flux de travail ou le graphique de chatflow actuel",
"gotoAnything.actions.refineDesc": "Décrire un changement à appliquer au brouillon actuel",
"gotoAnything.actions.refineTitle": "Affiner le graphique actuel",
"gotoAnything.actions.searchApplications": "Rechercher des applications",
-1
View File
@@ -68,7 +68,6 @@
"gotoAnything.actions.docDesc": "दस्तावेज़ खोलें",
"gotoAnything.actions.goDesc": "किसी पेज पर जाएँ",
"gotoAnything.actions.languageChangeDesc": "भाषा बदलें",
"gotoAnything.actions.refineCategoryDesc": "वर्तमान वर्कफ़्लो या चैटफ़्लो ग्राफ़ को परिष्कृत करें",
"gotoAnything.actions.refineDesc": "वर्मन मसौदे पर लागू हो रहा है",
"gotoAnything.actions.refineTitle": "वर्तमान ग्राफ़ को परिष्कृत करें",
"gotoAnything.actions.searchApplications": "एप्लिकेशन खोजें",
-1
View File
@@ -68,7 +68,6 @@
"gotoAnything.actions.docDesc": "Buka dokumentasi",
"gotoAnything.actions.goDesc": "Buka halaman",
"gotoAnything.actions.languageChangeDesc": "Ganti bahasa",
"gotoAnything.actions.refineCategoryDesc": "Sempurnakan alur kerja atau grafik alur obrolan saat ini",
"gotoAnything.actions.refineDesc": "Jelaskan perubahan yang akan diterapkan pada draf saat ini",
"gotoAnything.actions.refineTitle": "Sempurnakan grafik saat ini",
"gotoAnything.actions.searchApplications": "Cari Aplikasi",
-1
View File
@@ -68,7 +68,6 @@
"gotoAnything.actions.docDesc": "Apri documentazione",
"gotoAnything.actions.goDesc": "Vai a una pagina",
"gotoAnything.actions.languageChangeDesc": "Cambia lingua",
"gotoAnything.actions.refineCategoryDesc": "Perfeziona il flusso di lavoro corrente o il grafico del flusso di chat",
"gotoAnything.actions.refineDesc": "Descrivi una modifica da applicare alla bozza corrente",
"gotoAnything.actions.refineTitle": "Perfeziona il grafico corrente",
"gotoAnything.actions.searchApplications": "Cerca applicazioni",
-1
View File
@@ -68,7 +68,6 @@
"gotoAnything.actions.docDesc": "ドキュメントを開く",
"gotoAnything.actions.goDesc": "ページへ移動",
"gotoAnything.actions.languageChangeDesc": "言語を変更",
"gotoAnything.actions.refineCategoryDesc": "現在のワークフローまたはチャットフローグラフを改良する",
"gotoAnything.actions.refineDesc": "現在のドラフトに適用する変更について説明します",
"gotoAnything.actions.refineTitle": "現在のグラフを絞り込む",
"gotoAnything.actions.searchApplications": "アプリケーションを検索",
-1
View File
@@ -68,7 +68,6 @@
"gotoAnything.actions.docDesc": "문서 열기",
"gotoAnything.actions.goDesc": "페이지로 이동",
"gotoAnything.actions.languageChangeDesc": "언어 변경",
"gotoAnything.actions.refineCategoryDesc": "현재 워크플로우 또는 챗플로우 그래프를 구체화합니다.",
"gotoAnything.actions.refineDesc": "현재 초안에 적용할 변경 사항 설명",
"gotoAnything.actions.refineTitle": "현재 그래프 세분화",
"gotoAnything.actions.searchApplications": "응용 프로그램 검색",
-1
View File
@@ -68,7 +68,6 @@
"gotoAnything.actions.docDesc": "ເປີດເອກະສານ",
"gotoAnything.actions.goDesc": "ໄປທີ່ໜ້າ",
"gotoAnything.actions.languageChangeDesc": "ປ່ຽນພາສາ",
"gotoAnything.actions.refineCategoryDesc": "ປັບປຸງກຣາຟ Workflow ຫຼື Chatflow ປະຈຸບັນ",
"gotoAnything.actions.refineDesc": "ອະທິບາຍການປ່ຽນແປງທີ່ຈະນຳໃຊ້ກັບຮ່າງປະຈຸບັນ",
"gotoAnything.actions.refineTitle": "ປັບປຸງກຣາຟປະຈຸບັນ",
"gotoAnything.actions.searchApplications": "ຄົ້ນຫາແອັບພລິເຄຊັນ",
-1
View File
@@ -68,7 +68,6 @@
"gotoAnything.actions.docDesc": "Documentatie openen",
"gotoAnything.actions.goDesc": "Naar een pagina gaan",
"gotoAnything.actions.languageChangeDesc": "Taal wijzigen",
"gotoAnything.actions.refineCategoryDesc": "Verfijn de huidige workflow of chatflowgrafiek",
"gotoAnything.actions.refineDesc": "Beschrijf een wijziging die moet worden toegepast op het huidige concept",
"gotoAnything.actions.refineTitle": "Verfijn de huidige grafiek",
"gotoAnything.actions.searchApplications": "Search Applications",
-1
View File
@@ -68,7 +68,6 @@
"gotoAnything.actions.docDesc": "Otwórz dokumentację",
"gotoAnything.actions.goDesc": "Przejdź do strony",
"gotoAnything.actions.languageChangeDesc": "Zmień język",
"gotoAnything.actions.refineCategoryDesc": "Udoskonal bieżący przepływ pracy lub wykres przepływu rozmów",
"gotoAnything.actions.refineDesc": "Opisz zmianę, którą chcesz zastosować w bieżącej wersji roboczej",
"gotoAnything.actions.refineTitle": "Udoskonal bieżący wykres",
"gotoAnything.actions.searchApplications": "Szukaj aplikacji",
-1
View File
@@ -68,7 +68,6 @@
"gotoAnything.actions.docDesc": "Abrir documentação",
"gotoAnything.actions.goDesc": "Ir para uma página",
"gotoAnything.actions.languageChangeDesc": "Alterar idioma",
"gotoAnything.actions.refineCategoryDesc": "Refinar o fluxo de trabalho atual ou gráfico de fluxo de chat",
"gotoAnything.actions.refineDesc": "Descreva uma alteração a ser aplicada ao rascunho atual",
"gotoAnything.actions.refineTitle": "Refinar o gráfico atual",
"gotoAnything.actions.searchApplications": "Pesquisar aplicativos",
-1
View File
@@ -68,7 +68,6 @@
"gotoAnything.actions.docDesc": "Deschide documentația",
"gotoAnything.actions.goDesc": "Mergi la o pagină",
"gotoAnything.actions.languageChangeDesc": "Schimbă limba",
"gotoAnything.actions.refineCategoryDesc": "Rafinați fluxul de lucru curent sau graficul fluxului de chat",
"gotoAnything.actions.refineDesc": "Descrieți o modificare de aplicat schiței actuale",
"gotoAnything.actions.refineTitle": "Rafinați graficul curent",
"gotoAnything.actions.searchApplications": "Căutare aplicații",
-1
View File
@@ -68,7 +68,6 @@
"gotoAnything.actions.docDesc": "Открыть документацию",
"gotoAnything.actions.goDesc": "Перейти на страницу",
"gotoAnything.actions.languageChangeDesc": "Сменить язык",
"gotoAnything.actions.refineCategoryDesc": "Уточните текущий рабочий процесс или график потока чата",
"gotoAnything.actions.refineDesc": "Опишите изменение, которое будет применено к текущему проекту.",
"gotoAnything.actions.refineTitle": "Уточнить текущий график",
"gotoAnything.actions.searchApplications": "Поиск приложений",
-1
View File
@@ -68,7 +68,6 @@
"gotoAnything.actions.docDesc": "Odpri dokumentacijo",
"gotoAnything.actions.goDesc": "Pojdi na stran",
"gotoAnything.actions.languageChangeDesc": "Spremeni jezik",
"gotoAnything.actions.refineCategoryDesc": "Izboljšajte trenutni tok dela ali graf toka klepeta",
"gotoAnything.actions.refineDesc": "Opišite spremembo za trenutni osnutek",
"gotoAnything.actions.refineTitle": "Izboljšajte trenutni graf",
"gotoAnything.actions.searchApplications": "Iskanje aplikacij",
-1
View File
@@ -68,7 +68,6 @@
"gotoAnything.actions.docDesc": "เปิดเอกสาร",
"gotoAnything.actions.goDesc": "ไปยังหน้า",
"gotoAnything.actions.languageChangeDesc": "เปลี่ยนภาษา",
"gotoAnything.actions.refineCategoryDesc": "ปรับแต่งเวิร์กโฟลว์ปัจจุบันหรือกราฟโฟลว์แชท",
"gotoAnything.actions.refineDesc": "อธิบายการเปลี่ยนแปลงที่จะนำไปใช้กับร่างปัจจุบัน",
"gotoAnything.actions.refineTitle": "ปรับแต่งกราฟปัจจุบัน",
"gotoAnything.actions.searchApplications": "ค้นหาแอปพลิเคชัน",
-1
View File
@@ -68,7 +68,6 @@
"gotoAnything.actions.docDesc": "Belgeleri aç",
"gotoAnything.actions.goDesc": "Bir sayfaya git",
"gotoAnything.actions.languageChangeDesc": "Dili değiştir",
"gotoAnything.actions.refineCategoryDesc": "Mevcut iş akışını veya sohbet akışı grafiğini hassaslaştırın",
"gotoAnything.actions.refineDesc": "Mevcut taslağa uygulanacak bir değişikliği açıklayın",
"gotoAnything.actions.refineTitle": "Mevcut grafiği hassaslaştır",
"gotoAnything.actions.searchApplications": "Uygulamaları Ara",
-1
View File
@@ -68,7 +68,6 @@
"gotoAnything.actions.docDesc": "Відкрити документацію",
"gotoAnything.actions.goDesc": "Перейти на сторінку",
"gotoAnything.actions.languageChangeDesc": "Змінити мову",
"gotoAnything.actions.refineCategoryDesc": "Уточніть поточний робочий процес або графік чату",
"gotoAnything.actions.refineDesc": "Опишіть зміну, яку слід застосувати до поточної чернетки",
"gotoAnything.actions.refineTitle": "Уточнити поточний графік",
"gotoAnything.actions.searchApplications": "Пошук додатків",
-1
View File
@@ -68,7 +68,6 @@
"gotoAnything.actions.docDesc": "Mở tài liệu",
"gotoAnything.actions.goDesc": "Đi đến trang",
"gotoAnything.actions.languageChangeDesc": "Đổi ngôn ngữ",
"gotoAnything.actions.refineCategoryDesc": "Tinh chỉnh quy trình làm việc hiện tại hoặc biểu đồ luồng trò chuyện",
"gotoAnything.actions.refineDesc": "Mô tả một thay đổi để áp dụng cho dự thảo hiện tại",
"gotoAnything.actions.refineTitle": "Tinh chỉnh biểu đồ hiện tại",
"gotoAnything.actions.searchApplications": "Tìm kiếm ứng dụng",
-1
View File
@@ -68,7 +68,6 @@
"gotoAnything.actions.docDesc": "查看文档",
"gotoAnything.actions.goDesc": "前往页面",
"gotoAnything.actions.languageChangeDesc": "切换语言",
"gotoAnything.actions.refineCategoryDesc": "优化当前的工作流或 Chatflow 图",
"gotoAnything.actions.refineDesc": "描述要应用到当前草稿的修改",
"gotoAnything.actions.refineTitle": "优化当前图",
"gotoAnything.actions.searchApplications": "搜索应用程序",
-1
View File
@@ -68,7 +68,6 @@
"gotoAnything.actions.docDesc": "查看文件",
"gotoAnything.actions.goDesc": "前往頁面",
"gotoAnything.actions.languageChangeDesc": "切換語言",
"gotoAnything.actions.refineCategoryDesc": "優化目前工作流程或聊天流程圖",
"gotoAnything.actions.refineDesc": "描述適用於當前草案的更改",
"gotoAnything.actions.refineTitle": "細化當前圖表",
"gotoAnything.actions.searchApplications": "搜索應用程式",
-1
View File
@@ -552,7 +552,6 @@ export default interface Resources {
'gotoAnything.actions.docDesc': 'Open documentation'
'gotoAnything.actions.goDesc': 'Go to a page'
'gotoAnything.actions.languageChangeDesc': 'Change language'
'gotoAnything.actions.refineCategoryDesc': 'Refine the current workflow or chatflow graph'
'gotoAnything.actions.refineDesc': 'Describe a change to apply to the current draft'
'gotoAnything.actions.refineTitle': 'Refine current graph'
'gotoAnything.actions.searchApplications': 'Search Applications'
-11
View File
@@ -259,14 +259,3 @@ export function createI18nextMock(translations: TranslationMap = {}) {
}),
}
}
export function createReactI18nextLanguageMock(language: string) {
const mock = createReactI18nextMock()
return {
...mock,
getI18n: () => ({
...mock.getI18n(),
language,
}),
}
}