From d42276a5c015aaa3750a37a8d847e4a07083a498 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Tue, 29 Sep 2026 06:37:41 +0000 Subject: [PATCH] refactor(goto-anything): simplify command context and execution (#43136) --- oxlint-suppressions.json | 5 - web/app/components/goto-anything/README.md | 4 +- .../goto-anything/__tests__/index.spec.tsx | 140 +++++-- .../actions/__tests__/index.spec.ts | 53 +-- .../commands/__tests__/command-bus.spec.ts | 68 ---- .../commands/__tests__/commands.spec.tsx | 155 ++++++++ .../actions/commands/__tests__/context.ts | 20 + .../commands/__tests__/create.spec.tsx | 155 ++------ .../__tests__/direct-commands.spec.ts | 294 -------------- .../actions/commands/__tests__/go.spec.tsx | 130 ------ .../commands/__tests__/language.spec.ts | 82 ---- .../commands/__tests__/refine.spec.tsx | 139 ------- .../commands/__tests__/registry.spec.ts | 372 ++++-------------- .../actions/commands/__tests__/slash.spec.tsx | 202 ---------- .../actions/commands/__tests__/theme.spec.ts | 69 ---- .../actions/commands/account.tsx | 41 +- .../goto-anything/actions/commands/catalog.ts | 22 ++ .../actions/commands/command-bus.ts | 25 -- .../goto-anything/actions/commands/create.tsx | 77 ++-- .../actions/commands/discord.tsx | 35 +- .../goto-anything/actions/commands/docs.tsx | 45 +-- .../goto-anything/actions/commands/go.tsx | 46 +-- .../actions/commands/language.tsx | 44 +-- .../goto-anything/actions/commands/models.tsx | 37 +- .../goto-anything/actions/commands/refine.tsx | 87 ++-- .../actions/commands/registry.ts | 280 +++---------- .../actions/commands/slash-provider.tsx | 78 ---- .../goto-anything/actions/commands/slash.tsx | 50 +-- .../goto-anything/actions/commands/theme.tsx | 46 +-- .../goto-anything/actions/commands/types.ts | 75 ++-- .../actions/commands/use-command-context.ts | 64 +++ .../components/goto-anything/actions/index.ts | 15 +- .../components/goto-anything/actions/types.ts | 1 + .../goto-anything/command-options.ts | 21 +- web/app/components/goto-anything/index.tsx | 19 +- web/i18n/locales/ar-TN/app.json | 1 - web/i18n/locales/az-AZ/app.json | 1 - web/i18n/locales/de-DE/app.json | 1 - web/i18n/locales/en-US/app.json | 1 - web/i18n/locales/es-ES/app.json | 1 - web/i18n/locales/fa-IR/app.json | 1 - web/i18n/locales/fr-FR/app.json | 1 - web/i18n/locales/hi-IN/app.json | 1 - web/i18n/locales/id-ID/app.json | 1 - web/i18n/locales/it-IT/app.json | 1 - web/i18n/locales/ja-JP/app.json | 1 - web/i18n/locales/ko-KR/app.json | 1 - web/i18n/locales/lo-LA/app.json | 1 - web/i18n/locales/nl-NL/app.json | 1 - web/i18n/locales/pl-PL/app.json | 1 - web/i18n/locales/pt-BR/app.json | 1 - web/i18n/locales/ro-RO/app.json | 1 - web/i18n/locales/ru-RU/app.json | 1 - web/i18n/locales/sl-SI/app.json | 1 - web/i18n/locales/th-TH/app.json | 1 - web/i18n/locales/tr-TR/app.json | 1 - web/i18n/locales/uk-UA/app.json | 1 - web/i18n/locales/vi-VN/app.json | 1 - web/i18n/locales/zh-Hans/app.json | 1 - web/i18n/locales/zh-Hant/app.json | 1 - web/i18n/resources.generated.d.ts | 1 - web/test/i18n-mock.ts | 11 - 62 files changed, 745 insertions(+), 2288 deletions(-) delete mode 100644 web/app/components/goto-anything/actions/commands/__tests__/command-bus.spec.ts create mode 100644 web/app/components/goto-anything/actions/commands/__tests__/commands.spec.tsx create mode 100644 web/app/components/goto-anything/actions/commands/__tests__/context.ts delete mode 100644 web/app/components/goto-anything/actions/commands/__tests__/direct-commands.spec.ts delete mode 100644 web/app/components/goto-anything/actions/commands/__tests__/go.spec.tsx delete mode 100644 web/app/components/goto-anything/actions/commands/__tests__/language.spec.ts delete mode 100644 web/app/components/goto-anything/actions/commands/__tests__/refine.spec.tsx delete mode 100644 web/app/components/goto-anything/actions/commands/__tests__/slash.spec.tsx delete mode 100644 web/app/components/goto-anything/actions/commands/__tests__/theme.spec.ts create mode 100644 web/app/components/goto-anything/actions/commands/catalog.ts delete mode 100644 web/app/components/goto-anything/actions/commands/command-bus.ts delete mode 100644 web/app/components/goto-anything/actions/commands/slash-provider.tsx create mode 100644 web/app/components/goto-anything/actions/commands/use-command-context.ts diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 3d9137c4231..59cda43093f 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -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 diff --git a/web/app/components/goto-anything/README.md b/web/app/components/goto-anything/README.md index fd40ba97a71..d87f0d4a24f 100644 --- a/web/app/components/goto-anything/README.md +++ b/web/app/components/goto-anything/README.md @@ -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. diff --git a/web/app/components/goto-anything/__tests__/index.spec.tsx b/web/app/components/goto-anything/__tests__/index.spec.tsx index 02dfbaba742..20d915d7ac3 100644 --- a/web/app/components/goto-anything/__tests__/index.spec.tsx +++ b/web/app/components/goto-anything/__tests__/index.spec.tsx @@ -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 => actionsMock, ) const matchActionMock = vi.fn< (query: string, actions: Record) => 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() - 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() 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() 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() + 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() + 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() + 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() - 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() @@ -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() @@ -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() 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() 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() @@ -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() triggerSearchShortcut() diff --git a/web/app/components/goto-anything/actions/__tests__/index.spec.ts b/web/app/components/goto-anything/actions/__tests__/index.spec.ts index aa6a59f4977..14673661970 100644 --- a/web/app/components/goto-anything/actions/__tests__/index.spec.ts +++ b/web/app/components/goto-anything/actions/__tests__/index.spec.ts @@ -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).node!.title).toBe( + expect((createActions(slash, true, false) as Record).node!.title).toBe( 'Workflow Nodes', ) }) it('uses the RAG-owned node action when both graph flags are true', () => { - expect((createActions(true, true) as Record).node!.title).toBe( + expect((createActions(slash, true, true) as Record).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() }) diff --git a/web/app/components/goto-anything/actions/commands/__tests__/command-bus.spec.ts b/web/app/components/goto-anything/actions/commands/__tests__/command-bus.spec.ts deleted file mode 100644 index 559e7e1821b..00000000000 --- a/web/app/components/goto-anything/actions/commands/__tests__/command-bus.spec.ts +++ /dev/null @@ -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() - }) - }) -}) diff --git a/web/app/components/goto-anything/actions/commands/__tests__/commands.spec.tsx b/web/app/components/goto-anything/actions/commands/__tests__/commands.spec.tsx new file mode 100644 index 00000000000..c86cf9d9aae --- /dev/null +++ b/web/app/components/goto-anything/actions/commands/__tests__/commands.spec.tsx @@ -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( +
    + {themeCommand.search('dark', context).map((result) => ( +
  • + {result.title} + {result.icon} +
  • + ))} +
, + ) + 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() + } + }) +}) diff --git a/web/app/components/goto-anything/actions/commands/__tests__/context.ts b/web/app/components/goto-anything/actions/commands/__tests__/context.ts new file mode 100644 index 00000000000..298213eb49f --- /dev/null +++ b/web/app/components/goto-anything/actions/commands/__tests__/context.ts @@ -0,0 +1,20 @@ +import type { CommandContext } from '../types' +import { withSelectorKey } from '@/test/i18n-mock' + +export function createCommandContext(overrides: Partial = {}): 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, + } +} diff --git a/web/app/components/goto-anything/actions/commands/__tests__/create.spec.tsx b/web/app/components/goto-anything/actions/commands/__tests__/create.spec.tsx index 9de17e5e51e..c025a34abe2 100644 --- a/web/app/components/goto-anything/actions/commands/__tests__/create.spec.tsx +++ b/web/app/components/goto-anything/actions/commands/__tests__/create.spec.tsx @@ -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() - }) }) }) diff --git a/web/app/components/goto-anything/actions/commands/__tests__/direct-commands.spec.ts b/web/app/components/goto-anything/actions/commands/__tests__/direct-commands.spec.ts deleted file mode 100644 index d1f8c922843..00000000000 --- a/web/app/components/goto-anything/actions/commands/__tests__/direct-commands.spec.ts +++ /dev/null @@ -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) - 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) - expect(registerCommands).toHaveBeenCalledWith({ 'navigation.discord': expect.any(Function) }) - }) - - it('registered handler opens URL from args', async () => { - discordCommand.register?.({} as Record) - 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) - 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) - 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']) - }) -}) diff --git a/web/app/components/goto-anything/actions/commands/__tests__/go.spec.tsx b/web/app/components/goto-anything/actions/commands/__tests__/go.spec.tsx deleted file mode 100644 index ff8ca96912e..00000000000 --- a/web/app/components/goto-anything/actions/commands/__tests__/go.spec.tsx +++ /dev/null @@ -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') - }) - }) -}) diff --git a/web/app/components/goto-anything/actions/commands/__tests__/language.spec.ts b/web/app/components/goto-anything/actions/commands/__tests__/language.spec.ts deleted file mode 100644 index dbf84f2f351..00000000000 --- a/web/app/components/goto-anything/actions/commands/__tests__/language.spec.ts +++ /dev/null @@ -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') - }) - }) -}) diff --git a/web/app/components/goto-anything/actions/commands/__tests__/refine.spec.tsx b/web/app/components/goto-anything/actions/commands/__tests__/refine.spec.tsx deleted file mode 100644 index b3d59487abd..00000000000 --- a/web/app/components/goto-anything/actions/commands/__tests__/refine.spec.tsx +++ /dev/null @@ -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() - }) - }) -}) diff --git a/web/app/components/goto-anything/actions/commands/__tests__/registry.spec.ts b/web/app/components/goto-anything/actions/commands/__tests__/registry.spec.ts index 46080803011..787ea82ab49 100644 --- a/web/app/components/goto-anything/actions/commands/__tests__/registry.spec.ts +++ b/web/app/components/goto-anything/actions/commands/__tests__/registry.spec.ts @@ -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 { - 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 => ({ + 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() }) }) diff --git a/web/app/components/goto-anything/actions/commands/__tests__/slash.spec.tsx b/web/app/components/goto-anything/actions/commands/__tests__/slash.spec.tsx deleted file mode 100644 index dbc1c3993d2..00000000000 --- a/web/app/components/goto-anything/actions/commands/__tests__/slash.spec.tsx +++ /dev/null @@ -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() - - 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() - - 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() - - 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(, { - 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() - }) -}) diff --git a/web/app/components/goto-anything/actions/commands/__tests__/theme.spec.ts b/web/app/components/goto-anything/actions/commands/__tests__/theme.spec.ts deleted file mode 100644 index a6f75cd7a84..00000000000 --- a/web/app/components/goto-anything/actions/commands/__tests__/theme.spec.ts +++ /dev/null @@ -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') - }) - }) -}) diff --git a/web/app/components/goto-anything/actions/commands/account.tsx b/web/app/components/goto-anything/actions/commands/account.tsx index 6d8c2d6e87d..2dd80b289b0 100644 --- a/web/app/components/goto-anything/actions/commands/account.tsx +++ b/web/app/components/goto-anything/actions/commands/account.tsx @@ -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 +import type { SlashCommand } from './types' /** * Account command - Navigates to account page */ -export const accountCommand: SlashCommandHandler = { +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 = { ), - 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']) - }, } diff --git a/web/app/components/goto-anything/actions/commands/catalog.ts b/web/app/components/goto-anything/actions/commands/catalog.ts new file mode 100644 index 00000000000..e1b6e5fd681 --- /dev/null +++ b/web/app/components/goto-anything/actions/commands/catalog.ts @@ -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] : []), +]) diff --git a/web/app/components/goto-anything/actions/commands/command-bus.ts b/web/app/components/goto-anything/actions/commands/command-bus.ts deleted file mode 100644 index a396ae312f7..00000000000 --- a/web/app/components/goto-anything/actions/commands/command-bus.ts +++ /dev/null @@ -1,25 +0,0 @@ -type CommandHandler = (args?: Record) => void | Promise - -const handlers = new Map() - -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) => { - const handler = handlers.get(name) - if (!handler) return - await handler(args) -} - -export const registerCommands = (map: Record) => { - Object.entries(map).forEach(([name, handler]) => registerCommand(name, handler)) -} - -export const unregisterCommands = (names: string[]) => { - names.forEach(unregisterCommand) -} diff --git a/web/app/components/goto-anything/actions/commands/create.tsx b/web/app/components/goto-anything/actions/commands/create.tsx index 435394415c3..cec63c3d604 100644 --- a/web/app/components/goto-anything/actions/commands/create.tsx +++ b/web/app/components/goto-anything/actions/commands/create.tsx @@ -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) => (
@@ -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']) - }, } diff --git a/web/app/components/goto-anything/actions/commands/discord.tsx b/web/app/components/goto-anything/actions/commands/discord.tsx index dae193c2d04..920856db65a 100644 --- a/web/app/components/goto-anything/actions/commands/discord.tsx +++ b/web/app/components/goto-anything/actions/commands/discord.tsx @@ -1,49 +1,32 @@ -import type { SlashCommandHandler } from './types' -import { getI18n } from 'react-i18next' -import { registerCommands, unregisterCommands } from './command-bus' - -type DiscordDeps = Record +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 = { +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: (
), - 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']) - }, } diff --git a/web/app/components/goto-anything/actions/commands/docs.tsx b/web/app/components/goto-anything/actions/commands/docs.tsx index 83d19cdb866..26d3ed2123a 100644 --- a/web/app/components/goto-anything/actions/commands/docs.tsx +++ b/web/app/components/goto-anything/actions/commands/docs.tsx @@ -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 = { +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 = {
), - 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']) - }, } diff --git a/web/app/components/goto-anything/actions/commands/go.tsx b/web/app/components/goto-anything/actions/commands/go.tsx index ceeb1553222..d5b3c716b43 100644 --- a/web/app/components/goto-anything/actions/commands/go.tsx +++ b/web/app/components/goto-anything/actions/commands/go.tsx @@ -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 = { +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 = { ), - 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']) - }, } diff --git a/web/app/components/goto-anything/actions/commands/language.tsx b/web/app/components/goto-anything/actions/commands/language.tsx index 0d064c60821..a12f4ad18b9 100644 --- a/web/app/components/goto-anything/actions/commands/language.tsx +++ b/web/app/components/goto-anything/actions/commands/language.tsx @@ -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 -} - -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 = { +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) }, } diff --git a/web/app/components/goto-anything/actions/commands/models.tsx b/web/app/components/goto-anything/actions/commands/models.tsx index 35e198f6fd9..52c2f085ef8 100644 --- a/web/app/components/goto-anything/actions/commands/models.tsx +++ b/web/app/components/goto-anything/actions/commands/models.tsx @@ -1,34 +1,25 @@ -import type { SlashCommandHandler } from './types' -import { getI18n } from 'react-i18next' -import { registerCommands, unregisterCommands } from './command-bus' - -type ModelsDeps = Record +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 = { +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 = { ), - data: { command: 'navigation.models' }, + data: { command: 'models' }, }, ] }, - - register(_deps: ModelsDeps) { - registerCommands({ - 'navigation.models': async () => openSystemModels(), - }) - }, - - unregister() { - unregisterCommands(['navigation.models']) - }, } diff --git a/web/app/components/goto-anything/actions/commands/refine.tsx b/web/app/components/goto-anything/actions/commands/refine.tsx index 9c4e68a6741..1e93214aabf 100644 --- a/web/app/components/goto-anything/actions/commands/refine.tsx +++ b/web/app/components/goto-anything/actions/commands/refine.tsx @@ -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 = { ), - data: { command: 'refine.open', args: {} }, + data: { command: 'refine', args: {} }, }, ] }, - - register() { - registerCommands({ - 'refine.open': async () => openRefineGenerator(), - }) - }, - - unregister() { - unregisterCommands(['refine.open']) - }, } diff --git a/web/app/components/goto-anything/actions/commands/registry.ts b/web/app/components/goto-anything/actions/commands/registry.ts index 4a79ac41167..3d56ded798d 100644 --- a/web/app/components/goto-anything/actions/commands/registry.ts +++ b/web/app/components/goto-anything/actions/commands/registry.ts @@ -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() - private snapshot: SlashCommand[] = [] - private listeners = new Set<() => void>() + private readonly commands: readonly SlashCommand[] + private readonly byName = new Map() - 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() - - /** - * Register command handler - */ - register(handler: SlashCommandHandler, 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, 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() - 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() diff --git a/web/app/components/goto-anything/actions/commands/slash-provider.tsx b/web/app/components/goto-anything/actions/commands/slash-provider.tsx deleted file mode 100644 index 5589c4f83f9..00000000000 --- a/web/app/components/goto-anything/actions/commands/slash-provider.tsx +++ /dev/null @@ -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, - }) - 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 -} diff --git a/web/app/components/goto-anything/actions/commands/slash.tsx b/web/app/components/goto-anything/actions/commands/slash.tsx index 005a841da67..0003e08629e 100644 --- a/web/app/components/goto-anything/actions/commands/slash.tsx +++ b/web/app/components/goto-anything/actions/commands/slash.tsx @@ -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), + } } diff --git a/web/app/components/goto-anything/actions/commands/theme.tsx b/web/app/components/goto-anything/actions/commands/theme.tsx index be38b31c060..33a7052c375 100644 --- a/web/app/components/goto-anything/actions/commands/theme.tsx +++ b/web/app/components/goto-anything/actions/commands/theme.tsx @@ -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: (
), - 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 = { +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) }, } diff --git a/web/app/components/goto-anything/actions/commands/types.ts b/web/app/components/goto-anything/actions/commands/types.ts index 8f16ffe84a4..cbd29881ea7 100644 --- a/web/app/components/goto-anything/actions/commands/types.ts +++ b/web/app/components/goto-anything/actions/commands/types.ts @@ -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 = { - /** 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 - - /** - * 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 + getDocsHomeUrl: () => string + onError: (error: unknown) => void + navigate: (path: string) => void + openExternal: (url: string) => void + openGenerator: ReturnType['openGenerator'] } -export type SlashCommand = Omit +export type SlashCommand = { + name: string + aliases?: readonly string[] + description: string + mode?: 'direct' | 'submenu' + isAvailable?: (context: CommandContext) => boolean + execute: (args: Record, context: CommandContext) => void | Promise + search: (args: string, context: CommandContext) => CommandSearchResult[] +} diff --git a/web/app/components/goto-anything/actions/commands/use-command-context.ts b/web/app/components/goto-anything/actions/commands/use-command-context.ts new file mode 100644 index 00000000000..dc4b67d6df2 --- /dev/null +++ b/web/app/components/goto-anything/actions/commands/use-command-context.ts @@ -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( + () => ({ + 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, + ], + ) +} diff --git a/web/app/components/goto-anything/actions/index.ts b/web/app/components/goto-anything/actions/index.ts index c4c763c2d89..97264caeebe 100644 --- a/web/app/components/goto-anything/actions/index.ts +++ b/web/app/components/goto-anything/actions/index.ts @@ -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) { 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) }) diff --git a/web/app/components/goto-anything/actions/types.ts b/web/app/components/goto-anything/actions/types.ts index 7055ff75336..05111836ee1 100644 --- a/web/app/components/goto-anything/actions/types.ts +++ b/web/app/components/goto-anything/actions/types.ts @@ -73,6 +73,7 @@ type ActionItemBase = { shortcut: string title: string | TypeWithI18N description: string + matches?: (query: string) => boolean action?: (data: SearchResult) => void } diff --git a/web/app/components/goto-anything/command-options.ts b/web/app/components/goto-anything/command-options.ts index 81f360a1f2c..1731b65ff43 100644 --- a/web/app/components/goto-anything/command-options.ts +++ b/web/app/components/goto-anything/command-options.ts @@ -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, 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, + 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, 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, diff --git a/web/app/components/goto-anything/index.tsx b/web/app/components/goto-anything/index.tsx index 0b1ff6f5ad4..dce824b966c 100644 --- a/web/app/components/goto-anything/index.tsx +++ b/web/app/components/goto-anything/index.tsx @@ -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() const inputRef = useRef(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 ( <> - ({ - ...mock.getI18n(), - language, - }), - } -}