From 5aba1112972e4f4e3700c69ff1d4378a2d785b4c Mon Sep 17 00:00:00 2001 From: GuanMu Date: Thu, 27 Nov 2025 20:10:50 +0800 Subject: [PATCH] Feat zen mode (#28794) --- .../actions/commands/registry.ts | 58 +++++++++++-------- .../goto-anything/actions/commands/slash.tsx | 3 + .../goto-anything/actions/commands/types.ts | 7 +++ .../goto-anything/actions/commands/zen.tsx | 58 +++++++++++++++++++ .../goto-anything/command-selector.tsx | 10 +++- web/app/components/goto-anything/index.tsx | 3 +- .../workflow/hooks/use-shortcuts.ts | 15 ++++- web/i18n/en-US/app.ts | 2 + web/i18n/zh-Hans/app.ts | 2 + 9 files changed, 130 insertions(+), 28 deletions(-) create mode 100644 web/app/components/goto-anything/actions/commands/zen.tsx diff --git a/web/app/components/goto-anything/actions/commands/registry.ts b/web/app/components/goto-anything/actions/commands/registry.ts index 3632db323e..d78e778480 100644 --- a/web/app/components/goto-anything/actions/commands/registry.ts +++ b/web/app/components/goto-anything/actions/commands/registry.ts @@ -70,11 +70,12 @@ export class SlashCommandRegistry { // First check if any alias starts with this const aliasMatch = this.findHandlerByAliasPrefix(lowerPartial) - if (aliasMatch) + if (aliasMatch && this.isCommandAvailable(aliasMatch)) return aliasMatch // Then check if command name starts with this - return this.findHandlerByNamePrefix(lowerPartial) + const nameMatch = this.findHandlerByNamePrefix(lowerPartial) + return nameMatch && this.isCommandAvailable(nameMatch) ? nameMatch : undefined } /** @@ -108,6 +109,14 @@ export class SlashCommandRegistry { return Array.from(uniqueCommands.values()) } + /** + * Get all available commands in current context (deduplicated and filtered) + * Commands without isAvailable method are considered always available + */ + getAvailableCommands(): SlashCommandHandler[] { + return this.getAllCommands().filter(handler => this.isCommandAvailable(handler)) + } + /** * Search commands * @param query Full query (e.g., "/theme dark" or "/lang en") @@ -128,7 +137,7 @@ export class SlashCommandRegistry { // First try exact match let handler = this.findCommand(commandName) - if (handler) { + if (handler && this.isCommandAvailable(handler)) { try { return await handler.search(args, locale) } @@ -140,7 +149,7 @@ export class SlashCommandRegistry { // If no exact match, try smart partial matching handler = this.findBestPartialMatch(commandName) - if (handler) { + if (handler && this.isCommandAvailable(handler)) { try { return await handler.search(args, locale) } @@ -156,35 +165,30 @@ export class SlashCommandRegistry { /** * Get root level command list + * Only shows commands that are available in current context */ private async getRootCommands(): Promise { - const results: CommandSearchResult[] = [] - - // Generate a root level item for each command - for (const handler of this.getAllCommands()) { - results.push({ - id: `root-${handler.name}`, - title: `/${handler.name}`, - description: handler.description, - type: 'command' as const, - data: { - command: `root.${handler.name}`, - args: { name: handler.name }, - }, - }) - } - - return results + 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[] = [] - this.getAllCommands().forEach((handler) => { + for (const handler of this.getAvailableCommands()) { // Check if command name matches if (handler.name.toLowerCase().includes(lowercaseQuery)) { matches.push({ @@ -216,7 +220,7 @@ export class SlashCommandRegistry { } }) } - }) + } return matches } @@ -227,6 +231,14 @@ export class SlashCommandRegistry { getCommandDependencies(commandName: string): any { 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: SlashCommandHandler) { + return handler.isAvailable?.() ?? true + } } // Global registry instance diff --git a/web/app/components/goto-anything/actions/commands/slash.tsx b/web/app/components/goto-anything/actions/commands/slash.tsx index b99215255f..35fdf40e7d 100644 --- a/web/app/components/goto-anything/actions/commands/slash.tsx +++ b/web/app/components/goto-anything/actions/commands/slash.tsx @@ -11,6 +11,7 @@ import { forumCommand } from './forum' import { docsCommand } from './docs' import { communityCommand } from './community' import { accountCommand } from './account' +import { zenCommand } from './zen' import i18n from '@/i18n-config/i18next-config' export const slashAction: ActionItem = { @@ -38,6 +39,7 @@ export const registerSlashCommands = (deps: Record) => { slashCommandRegistry.register(docsCommand, {}) slashCommandRegistry.register(communityCommand, {}) slashCommandRegistry.register(accountCommand, {}) + slashCommandRegistry.register(zenCommand, {}) } export const unregisterSlashCommands = () => { @@ -48,6 +50,7 @@ export const unregisterSlashCommands = () => { slashCommandRegistry.unregister('docs') slashCommandRegistry.unregister('community') slashCommandRegistry.unregister('account') + slashCommandRegistry.unregister('zen') } export const SlashCommandProvider = () => { diff --git a/web/app/components/goto-anything/actions/commands/types.ts b/web/app/components/goto-anything/actions/commands/types.ts index 75f8a8c1d6..528883c25f 100644 --- a/web/app/components/goto-anything/actions/commands/types.ts +++ b/web/app/components/goto-anything/actions/commands/types.ts @@ -21,6 +21,13 @@ export type SlashCommandHandler = { */ 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 diff --git a/web/app/components/goto-anything/actions/commands/zen.tsx b/web/app/components/goto-anything/actions/commands/zen.tsx new file mode 100644 index 0000000000..729f5c8639 --- /dev/null +++ b/web/app/components/goto-anything/actions/commands/zen.tsx @@ -0,0 +1,58 @@ +import type { SlashCommandHandler } from './types' +import React from 'react' +import { RiFullscreenLine } from '@remixicon/react' +import i18n from '@/i18n-config/i18next-config' +import { registerCommands, unregisterCommands } from './command-bus' +import { isInWorkflowPage } from '@/app/components/workflow/constants' + +// Zen command dependency types - no external dependencies needed +type ZenDeps = Record + +// Custom event name for zen toggle +export const ZEN_TOGGLE_EVENT = 'zen-toggle-maximize' + +// Shared function to dispatch zen toggle event +const toggleZenMode = () => { + window.dispatchEvent(new CustomEvent(ZEN_TOGGLE_EVENT)) +} + +/** + * Zen command - Toggle canvas maximize (focus mode) in workflow pages + * Only available in workflow and chatflow pages + */ +export const zenCommand: SlashCommandHandler = { + name: 'zen', + description: 'Toggle canvas focus mode', + mode: 'direct', + + // Only available in workflow/chatflow pages + isAvailable: () => isInWorkflowPage(), + + // Direct execution function + execute: toggleZenMode, + + async search(_args: string, locale: string = 'en') { + return [{ + id: 'zen', + title: i18n.t('app.gotoAnything.actions.zenTitle', { lng: locale }) || 'Zen Mode', + description: i18n.t('app.gotoAnything.actions.zenDesc', { lng: locale }) || 'Toggle canvas focus mode', + type: 'command' as const, + icon: ( +
+ +
+ ), + data: { command: 'workflow.zen', args: {} }, + }] + }, + + register(_deps: ZenDeps) { + registerCommands({ + 'workflow.zen': async () => toggleZenMode(), + }) + }, + + unregister() { + unregisterCommands(['workflow.zen']) + }, +} diff --git a/web/app/components/goto-anything/command-selector.tsx b/web/app/components/goto-anything/command-selector.tsx index a79edf4d4c..b17d508520 100644 --- a/web/app/components/goto-anything/command-selector.tsx +++ b/web/app/components/goto-anything/command-selector.tsx @@ -1,5 +1,6 @@ import type { FC } from 'react' import { useEffect, useMemo } from 'react' +import { usePathname } from 'next/navigation' import { Command } from 'cmdk' import { useTranslation } from 'react-i18next' import type { ActionItem } from './actions/types' @@ -16,18 +17,20 @@ type Props = { const CommandSelector: FC = ({ actions, onCommandSelect, searchFilter, commandValue, onCommandValueChange, originalQuery }) => { const { t } = useTranslation() + const pathname = usePathname() // Check if we're in slash command mode const isSlashMode = originalQuery?.trim().startsWith('/') || false // Get slash commands from registry + // Note: pathname is included in deps because some commands (like /zen) check isAvailable based on current route const slashCommands = useMemo(() => { if (!isSlashMode) return [] - const allCommands = slashCommandRegistry.getAllCommands() + const availableCommands = slashCommandRegistry.getAvailableCommands() const filter = searchFilter?.toLowerCase() || '' // searchFilter already has '/' removed - return allCommands.filter((cmd) => { + return availableCommands.filter((cmd) => { if (!filter) return true return cmd.name.toLowerCase().includes(filter) }).map(cmd => ({ @@ -36,7 +39,7 @@ const CommandSelector: FC = ({ actions, onCommandSelect, searchFilter, co title: cmd.name, description: cmd.description, })) - }, [isSlashMode, searchFilter]) + }, [isSlashMode, searchFilter, pathname]) const filteredActions = useMemo(() => { if (isSlashMode) return [] @@ -107,6 +110,7 @@ const CommandSelector: FC = ({ actions, onCommandSelect, searchFilter, co '/feedback': 'app.gotoAnything.actions.feedbackDesc', '/docs': 'app.gotoAnything.actions.docDesc', '/community': 'app.gotoAnything.actions.communityDesc', + '/zen': 'app.gotoAnything.actions.zenDesc', } return t(slashKeyMap[item.key] || item.description) })() diff --git a/web/app/components/goto-anything/index.tsx b/web/app/components/goto-anything/index.tsx index c0aaf14cec..1f153190f2 100644 --- a/web/app/components/goto-anything/index.tsx +++ b/web/app/components/goto-anything/index.tsx @@ -303,7 +303,8 @@ const GotoAnything: FC = ({ const handler = slashCommandRegistry.findCommand(commandName) // If it's a direct mode command, execute immediately - if (handler?.mode === 'direct' && handler.execute) { + const isAvailable = handler?.isAvailable?.() ?? true + if (handler?.mode === 'direct' && handler.execute && isAvailable) { e.preventDefault() handler.execute() setShow(false) diff --git a/web/app/components/workflow/hooks/use-shortcuts.ts b/web/app/components/workflow/hooks/use-shortcuts.ts index e8c69ca9b5..16502c97c4 100644 --- a/web/app/components/workflow/hooks/use-shortcuts.ts +++ b/web/app/components/workflow/hooks/use-shortcuts.ts @@ -1,6 +1,7 @@ import { useReactFlow } from 'reactflow' import { useKeyPress } from 'ahooks' -import { useCallback } from 'react' +import { useCallback, useEffect } from 'react' +import { ZEN_TOGGLE_EVENT } from '@/app/components/goto-anything/actions/commands/zen' import { getKeyboardKeyCodeBySystem, isEventTargetInputArea, @@ -246,4 +247,16 @@ export const useShortcuts = (): void => { events: ['keyup'], }, ) + + // Listen for zen toggle event from /zen command + useEffect(() => { + const handleZenToggle = () => { + handleToggleMaximizeCanvas() + } + + window.addEventListener(ZEN_TOGGLE_EVENT, handleZenToggle) + return () => { + window.removeEventListener(ZEN_TOGGLE_EVENT, handleZenToggle) + } + }, [handleToggleMaximizeCanvas]) } diff --git a/web/i18n/en-US/app.ts b/web/i18n/en-US/app.ts index 694329ee14..1f41d3601e 100644 --- a/web/i18n/en-US/app.ts +++ b/web/i18n/en-US/app.ts @@ -325,6 +325,8 @@ const translation = { communityDesc: 'Open Discord community', docDesc: 'Open help documentation', feedbackDesc: 'Open community feedback discussions', + zenTitle: 'Zen Mode', + zenDesc: 'Toggle canvas focus mode', }, emptyState: { noAppsFound: 'No apps found', diff --git a/web/i18n/zh-Hans/app.ts b/web/i18n/zh-Hans/app.ts index f27aed770c..517c41de10 100644 --- a/web/i18n/zh-Hans/app.ts +++ b/web/i18n/zh-Hans/app.ts @@ -324,6 +324,8 @@ const translation = { communityDesc: '打开 Discord 社区', docDesc: '打开帮助文档', feedbackDesc: '打开社区反馈讨论', + zenTitle: '专注模式', + zenDesc: '切换画布专注模式', }, emptyState: { noAppsFound: '未找到应用',