From 68c220d25edea5288e571de4728d72143ff14779 Mon Sep 17 00:00:00 2001 From: yyh Date: Sat, 27 Dec 2025 14:49:55 +0800 Subject: [PATCH] feat: implement banana command with search and registration functionality - Added `bananaCommand` to handle vibe-related actions, including search and command registration. - Updated `command-selector` to utilize new internationalization maps for slash commands and scope actions. - Removed deprecated banana action from the actions index and adjusted related filtering logic. - Added unit tests for the banana command to ensure correct behavior in various scenarios. - Deleted obsolete banana action tests and files. --- .../goto-anything/actions/banana.spec.tsx | 87 -------- .../actions/commands/banana.spec.tsx | 189 ++++++++++++++++++ .../actions/{ => commands}/banana.tsx | 42 +++- .../goto-anything/actions/commands/slash.tsx | 3 + .../components/goto-anything/actions/index.ts | 7 +- .../components/goto-anything/actions/types.ts | 2 +- .../goto-anything/command-selector.tsx | 2 +- 7 files changed, 227 insertions(+), 105 deletions(-) delete mode 100644 web/app/components/goto-anything/actions/banana.spec.tsx create mode 100644 web/app/components/goto-anything/actions/commands/banana.spec.tsx rename web/app/components/goto-anything/actions/{ => commands}/banana.tsx (53%) diff --git a/web/app/components/goto-anything/actions/banana.spec.tsx b/web/app/components/goto-anything/actions/banana.spec.tsx deleted file mode 100644 index ec7cd36c8e..0000000000 --- a/web/app/components/goto-anything/actions/banana.spec.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import type { CommandSearchResult, SearchResult } from './types' -import { isInWorkflowPage } from '@/app/components/workflow/constants' -import i18n from '@/i18n-config/i18next-config' -import { bananaAction } from './banana' - -vi.mock('@/i18n-config/i18next-config', () => ({ - default: { - t: vi.fn((key: string, options?: Record) => { - if (!options) - return key - return `${key}:${JSON.stringify(options)}` - }), - }, -})) - -vi.mock('@/app/components/workflow/constants', async () => { - const actual = await vi.importActual( - '@/app/components/workflow/constants', - ) - return { - ...actual, - isInWorkflowPage: vi.fn(), - } -}) - -const mockedIsInWorkflowPage = vi.mocked(isInWorkflowPage) -const mockedT = vi.mocked(i18n.t) - -const getCommandResult = (item: SearchResult): CommandSearchResult => { - expect(item.type).toBe('command') - return item as CommandSearchResult -} - -beforeEach(() => { - vi.clearAllMocks() -}) - -// Search behavior for the banana action. -describe('bananaAction', () => { - // Search results depend on workflow context and input content. - describe('search', () => { - it('should return no results when not on workflow page', async () => { - // Arrange - mockedIsInWorkflowPage.mockReturnValue(false) - - // Act - const result = await bananaAction.search('', '', 'en') - - // Assert - expect(result).toEqual([]) - }) - - it('should return hint description when input is blank', async () => { - // Arrange - mockedIsInWorkflowPage.mockReturnValue(true) - - // Act - const result = await bananaAction.search('', ' ', 'en') - - // Assert - expect(result).toHaveLength(1) - const [item] = result - const commandItem = getCommandResult(item) - expect(item.description).toContain('app.gotoAnything.actions.vibeHint') - expect(commandItem.data.args?.dsl).toBe('') - expect(mockedT).toHaveBeenCalledWith( - 'app.gotoAnything.actions.vibeHint', - expect.objectContaining({ prompt: expect.any(String), lng: 'en' }), - ) - }) - - it('should return default description when input is provided', async () => { - // Arrange - mockedIsInWorkflowPage.mockReturnValue(true) - - // Act - const result = await bananaAction.search('', ' build a flow ', 'en') - - // Assert - expect(result).toHaveLength(1) - const [item] = result - const commandItem = getCommandResult(item) - expect(item.description).toContain('app.gotoAnything.actions.vibeDesc') - expect(commandItem.data.args?.dsl).toBe('build a flow') - }) - }) -}) diff --git a/web/app/components/goto-anything/actions/commands/banana.spec.tsx b/web/app/components/goto-anything/actions/commands/banana.spec.tsx new file mode 100644 index 0000000000..47a1418aba --- /dev/null +++ b/web/app/components/goto-anything/actions/commands/banana.spec.tsx @@ -0,0 +1,189 @@ +import { isInWorkflowPage, VIBE_COMMAND_EVENT } from '@/app/components/workflow/constants' +import i18n from '@/i18n-config/i18next-config' +import { bananaCommand } from './banana' +import { registerCommands, unregisterCommands } from './command-bus' + +vi.mock('@/i18n-config/i18next-config', () => ({ + default: { + t: vi.fn((key: string, options?: Record) => { + if (!options) + return key + return `${key}:${JSON.stringify(options)}` + }), + }, +})) + +vi.mock('@/app/components/workflow/constants', async () => { + const actual = await vi.importActual( + '@/app/components/workflow/constants', + ) + return { + ...actual, + isInWorkflowPage: vi.fn(), + } +}) + +vi.mock('./command-bus', () => ({ + registerCommands: vi.fn(), + unregisterCommands: vi.fn(), +})) + +const mockedIsInWorkflowPage = vi.mocked(isInWorkflowPage) +const mockedRegisterCommands = vi.mocked(registerCommands) +const mockedUnregisterCommands = vi.mocked(unregisterCommands) +const mockedT = vi.mocked(i18n.t) + +type CommandArgs = { dsl?: string } +type CommandMap = Record void | Promise> + +beforeEach(() => { + vi.clearAllMocks() +}) + +// Command availability, search, and registration behavior for banana command. +describe('bananaCommand', () => { + // Command metadata mirrors the static definition. + describe('metadata', () => { + it('should expose name, mode, and description', () => { + // Assert + expect(bananaCommand.name).toBe('banana') + expect(bananaCommand.mode).toBe('submenu') + expect(bananaCommand.description).toContain('app.gotoAnything.actions.vibeDesc') + }) + }) + + // Availability mirrors workflow page detection. + describe('availability', () => { + it('should return true when on workflow page', () => { + // Arrange + mockedIsInWorkflowPage.mockReturnValue(true) + + // Act + const available = bananaCommand.isAvailable?.() + + // Assert + expect(available).toBe(true) + expect(mockedIsInWorkflowPage).toHaveBeenCalledTimes(1) + }) + + it('should return false when not on workflow page', () => { + // Arrange + mockedIsInWorkflowPage.mockReturnValue(false) + + // Act + const available = bananaCommand.isAvailable?.() + + // Assert + expect(available).toBe(false) + expect(mockedIsInWorkflowPage).toHaveBeenCalledTimes(1) + }) + }) + + // Search results depend on provided arguments. + describe('search', () => { + it('should return hint description when args are empty', async () => { + // Arrange + mockedIsInWorkflowPage.mockReturnValue(true) + + // Act + const result = await bananaCommand.search(' ') + + // Assert + expect(result).toHaveLength(1) + const [item] = result + expect(item.description).toContain('app.gotoAnything.actions.vibeHint') + expect(item.data?.args?.dsl).toBe('') + expect(item.data?.command).toBe('workflow.vibe') + expect(mockedT).toHaveBeenCalledWith( + 'app.gotoAnything.actions.vibeTitle', + expect.objectContaining({ lng: 'en' }), + ) + expect(mockedT).toHaveBeenCalledWith( + 'app.gotoAnything.actions.vibeHint', + expect.objectContaining({ prompt: expect.any(String), lng: 'en' }), + ) + }) + + it('should return default description when args are provided', async () => { + // Arrange + mockedIsInWorkflowPage.mockReturnValue(true) + + // Act + const result = await bananaCommand.search(' make a flow ', 'fr') + + // Assert + expect(result).toHaveLength(1) + const [item] = result + expect(item.description).toContain('app.gotoAnything.actions.vibeDesc') + expect(item.data?.args?.dsl).toBe('make a flow') + expect(item.data?.command).toBe('workflow.vibe') + expect(mockedT).toHaveBeenCalledWith( + 'app.gotoAnything.actions.vibeTitle', + expect.objectContaining({ lng: 'fr' }), + ) + expect(mockedT).toHaveBeenCalledWith( + 'app.gotoAnything.actions.vibeDesc', + expect.objectContaining({ lng: 'fr' }), + ) + }) + + it('should fall back to Banana when title translation is empty', async () => { + // Arrange + mockedIsInWorkflowPage.mockReturnValue(true) + mockedT.mockImplementationOnce(() => '') + + // Act + const result = await bananaCommand.search('make a plan') + + // Assert + expect(result).toHaveLength(1) + expect(result[0]?.title).toBe('Banana') + }) + }) + + // Command registration and event dispatching. + describe('registration', () => { + it('should register the workflow vibe command', () => { + // Act + expect(bananaCommand.register).toBeDefined() + bananaCommand.register?.({}) + + // Assert + expect(mockedRegisterCommands).toHaveBeenCalledTimes(1) + const commands = mockedRegisterCommands.mock.calls[0]?.[0] as CommandMap + expect(commands['workflow.vibe']).toEqual(expect.any(Function)) + }) + + it('should dispatch vibe event when command handler runs', async () => { + // Arrange + const dispatchSpy = vi.spyOn(document, 'dispatchEvent') + expect(bananaCommand.register).toBeDefined() + bananaCommand.register?.({}) + expect(mockedRegisterCommands).toHaveBeenCalledTimes(1) + const commands = mockedRegisterCommands.mock.calls[0]?.[0] as CommandMap + + try { + // Act + await commands['workflow.vibe']?.({ dsl: 'hello' }) + + // Assert + expect(dispatchSpy).toHaveBeenCalledTimes(1) + const event = dispatchSpy.mock.calls[0][0] as CustomEvent + expect(event.type).toBe(VIBE_COMMAND_EVENT) + expect(event.detail).toEqual({ dsl: 'hello' }) + } + finally { + dispatchSpy.mockRestore() + } + }) + + it('should unregister workflow vibe command', () => { + // Act + expect(bananaCommand.unregister).toBeDefined() + bananaCommand.unregister?.() + + // Assert + expect(mockedUnregisterCommands).toHaveBeenCalledWith(['workflow.vibe']) + }) + }) +}) diff --git a/web/app/components/goto-anything/actions/banana.tsx b/web/app/components/goto-anything/actions/commands/banana.tsx similarity index 53% rename from web/app/components/goto-anything/actions/banana.tsx rename to web/app/components/goto-anything/actions/commands/banana.tsx index a4b4c21023..65e9972bbf 100644 --- a/web/app/components/goto-anything/actions/banana.tsx +++ b/web/app/components/goto-anything/actions/commands/banana.tsx @@ -1,21 +1,29 @@ -import type { ActionItem } from './types' +import type { SlashCommandHandler } from './types' import { RiSparklingFill } from '@remixicon/react' import * as React from 'react' -import { isInWorkflowPage } from '@/app/components/workflow/constants' +import { isInWorkflowPage, VIBE_COMMAND_EVENT } from '@/app/components/workflow/constants' import i18n from '@/i18n-config/i18next-config' +import { registerCommands, unregisterCommands } from './command-bus' + +type BananaDeps = Record const BANANA_PROMPT_EXAMPLE = 'Summarize a document, classify sentiment, then notify Slack' -export const bananaAction: ActionItem = { - key: '@banana', - shortcut: '@banana', - title: i18n.t('app.gotoAnything.actions.vibeTitle'), - description: i18n.t('app.gotoAnything.actions.vibeDesc'), - search: async (_query, searchTerm = '', locale) => { - if (!isInWorkflowPage()) - return [] +const dispatchVibeCommand = (input?: string) => { + if (typeof document === 'undefined') + return - const trimmed = searchTerm.trim() + document.dispatchEvent(new CustomEvent(VIBE_COMMAND_EVENT, { detail: { dsl: input } })) +} + +export const bananaCommand: SlashCommandHandler = { + name: 'banana', + description: i18n.t('app.gotoAnything.actions.vibeDesc'), + mode: 'submenu', + isAvailable: () => isInWorkflowPage(), + + async search(args: string, locale: string = 'en') { + const trimmed = args.trim() const hasInput = !!trimmed return [{ @@ -36,4 +44,16 @@ export const bananaAction: ActionItem = { }, }] }, + + register(_deps: BananaDeps) { + registerCommands({ + 'workflow.vibe': async (args) => { + dispatchVibeCommand(args?.dsl) + }, + }) + }, + + unregister() { + unregisterCommands(['workflow.vibe']) + }, } diff --git a/web/app/components/goto-anything/actions/commands/slash.tsx b/web/app/components/goto-anything/actions/commands/slash.tsx index 4c43b5b61e..1cab3a358c 100644 --- a/web/app/components/goto-anything/actions/commands/slash.tsx +++ b/web/app/components/goto-anything/actions/commands/slash.tsx @@ -5,6 +5,7 @@ import { useEffect } from 'react' import { setLocaleOnClient } from '@/i18n-config' import i18n from '@/i18n-config/i18next-config' import { accountCommand } from './account' +import { bananaCommand } from './banana' import { executeCommand } from './command-bus' import { communityCommand } from './community' import { docsCommand } from './docs' @@ -41,6 +42,7 @@ export const registerSlashCommands = (deps: Record) => { slashCommandRegistry.register(communityCommand, {}) slashCommandRegistry.register(accountCommand, {}) slashCommandRegistry.register(zenCommand, {}) + slashCommandRegistry.register(bananaCommand, {}) } export const unregisterSlashCommands = () => { @@ -52,6 +54,7 @@ export const unregisterSlashCommands = () => { slashCommandRegistry.unregister('community') slashCommandRegistry.unregister('account') slashCommandRegistry.unregister('zen') + slashCommandRegistry.unregister('banana') } export const SlashCommandProvider = () => { diff --git a/web/app/components/goto-anything/actions/index.ts b/web/app/components/goto-anything/actions/index.ts index abf1c077f8..024b6bfd2c 100644 --- a/web/app/components/goto-anything/actions/index.ts +++ b/web/app/components/goto-anything/actions/index.ts @@ -160,12 +160,11 @@ * - `@knowledge` / `@kb` - Search knowledge bases * - `@plugin` - Search plugins * - `@node` - Search workflow nodes (workflow pages only) - * - `/` - Execute slash commands (theme, language, etc.) + * - `/` - Execute slash commands (theme, language, banana, etc.) */ import type { ActionItem, SearchResult } from './types' import { appAction } from './app' -import { bananaAction } from './banana' import { slashAction } from './commands' import { slashCommandRegistry } from './commands/registry' import { knowledgeAction } from './knowledge' @@ -192,7 +191,6 @@ export const createActions = (isWorkflowPage: boolean, isRagPipelinePage: boolea else if (isWorkflowPage) { return { ...baseActions, - banana: bananaAction, node: workflowNodesAction, } } @@ -207,7 +205,6 @@ export const Actions = { app: appAction, knowledge: knowledgeAction, plugin: pluginAction, - banana: bananaAction, node: workflowNodesAction, } @@ -299,4 +296,4 @@ export const matchAction = (query: string, actions: Record) export * from './commands' export * from './types' -export { appAction, bananaAction, knowledgeAction, pluginAction, workflowNodesAction } +export { appAction, knowledgeAction, pluginAction, workflowNodesAction } diff --git a/web/app/components/goto-anything/actions/types.ts b/web/app/components/goto-anything/actions/types.ts index 7c3f77f27f..838195ad85 100644 --- a/web/app/components/goto-anything/actions/types.ts +++ b/web/app/components/goto-anything/actions/types.ts @@ -44,7 +44,7 @@ export type CommandSearchResult = { export type SearchResult = AppSearchResult | PluginSearchResult | KnowledgeSearchResult | WorkflowNodeSearchResult | CommandSearchResult export type ActionItem = { - key: '@app' | '@banana' | '@knowledge' | '@plugin' | '@node' | '/' + key: '@app' | '@knowledge' | '@plugin' | '@node' | '/' shortcut: string title: string | TypeWithI18N description: string diff --git a/web/app/components/goto-anything/command-selector.tsx b/web/app/components/goto-anything/command-selector.tsx index 2241e79e42..bef6bab347 100644 --- a/web/app/components/goto-anything/command-selector.tsx +++ b/web/app/components/goto-anything/command-selector.tsx @@ -116,6 +116,7 @@ const CommandSelector: FC = ({ actions, onCommandSelect, searchFilter, co '/docs': 'app.gotoAnything.actions.docDesc', '/community': 'app.gotoAnything.actions.communityDesc', '/zen': 'app.gotoAnything.actions.zenDesc', + '/banana': 'app.gotoAnything.actions.vibeDesc', } return t((slashKeyMap[item.key] || item.description) as any) })() @@ -127,7 +128,6 @@ const CommandSelector: FC = ({ actions, onCommandSelect, searchFilter, co '@plugin': 'app.gotoAnything.actions.searchPluginsDesc', '@knowledge': 'app.gotoAnything.actions.searchKnowledgeBasesDesc', '@node': 'app.gotoAnything.actions.searchWorkflowNodesDesc', - '@banana': 'app.gotoAnything.actions.vibeDesc', } return t(keyMap[item.key] as any) as string })()