From 86563971bffea987e3bb9c90001c977a82319c75 Mon Sep 17 00:00:00 2001 From: betterclever Date: Sat, 27 Dec 2025 08:26:39 +0530 Subject: [PATCH 1/2] feat(frontend): add Command Palette with Cmd+K shortcut - Add CommandPalette component with search, keyboard navigation, and action execution - Support searching across workflows, navigation pages, and quick actions - Add fuzzy search with keyword matching - Integrate Cmd+K/Ctrl+K global keyboard shortcut - Add Search button with keyboard hint in sidebar - Include quick actions: create workflow, toggle theme - Beautiful modal UI with grouped results and keyboard hints Signed-off-by: betterclever --- frontend/src/App.tsx | 101 ++-- frontend/src/components/layout/AppLayout.tsx | 37 +- .../command-palette/CommandPalette.tsx | 502 ++++++++++++++++++ .../src/features/command-palette/index.ts | 2 + .../useCommandPaletteKeyboard.ts | 36 ++ frontend/src/store/commandPaletteStore.ts | 28 + 6 files changed, 661 insertions(+), 45 deletions(-) create mode 100644 frontend/src/features/command-palette/CommandPalette.tsx create mode 100644 frontend/src/features/command-palette/index.ts create mode 100644 frontend/src/features/command-palette/useCommandPaletteKeyboard.ts create mode 100644 frontend/src/store/commandPaletteStore.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a37d9709..6ec3bfe9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -15,62 +15,75 @@ import { useAuthStoreIntegration } from '@/auth/store-integration' import { ProtectedRoute } from '@/components/auth/ProtectedRoute' import { AnalyticsRouterListener } from '@/features/analytics/AnalyticsRouterListener' import { PostHogClerkBridge } from '@/features/analytics/PostHogClerkBridge' +import { CommandPalette, useCommandPaletteKeyboard } from '@/features/command-palette' function AuthIntegration({ children }: { children: React.ReactNode }) { useAuthStoreIntegration() return <>{children} } +function CommandPaletteProvider({ children }: { children: React.ReactNode }) { + useCommandPaletteKeyboard() + return ( + <> + {children} + + + ) +} + function App() { return ( - {/* Analytics wiring */} - - - - - - } /> - - - - } - /> - - - - } - /> - - - - } - /> - } /> - } /> - } /> - } /> - } /> - } - /> - } /> - - - + + {/* Analytics wiring */} + + + + + + } /> + + + + } + /> + + + + } + /> + + + + } + /> + } /> + } /> + } /> + } /> + } /> + } + /> + } /> + + + + diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index c9671365..1c9b375b 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -3,7 +3,7 @@ import { Link, useLocation, useNavigate } from 'react-router-dom' import { Sidebar, SidebarHeader, SidebarContent, SidebarFooter, SidebarItem } from '@/components/ui/sidebar' import { AppTopBar } from '@/components/layout/AppTopBar' import { Button } from '@/components/ui/button' -import { Workflow, KeyRound, Plus, Plug, Archive, CalendarClock, Sun, Moon, Shield } from 'lucide-react' +import { Workflow, KeyRound, Plus, Plug, Archive, CalendarClock, Sun, Moon, Shield, Search, Command } from 'lucide-react' import React, { useState, useEffect, useCallback } from 'react' import { useAuthStore } from '@/store/authStore' import { hasAdminRole } from '@/utils/auth' @@ -13,6 +13,7 @@ import { env } from '@/config/env' import { useThemeStore } from '@/store/themeStore' import { cn } from '@/lib/utils' import { setMobilePlacementSidebarClose } from '@/components/layout/Sidebar' +import { useCommandPaletteStore } from '@/store/commandPaletteStore' interface AppLayoutProps { children: React.ReactNode @@ -66,6 +67,7 @@ export function AppLayout({ children }: AppLayoutProps) { const authProvider = useAuthProvider() const showUserButton = isAuthenticated || authProvider.name === 'clerk' const { theme, startTransition } = useThemeStore() + const openCommandPalette = useCommandPaletteStore((state) => state.open) // Get git SHA for version display (monorepo - same for frontend and backend) const gitSha = env.VITE_GIT_SHA @@ -383,6 +385,39 @@ export function AppLayout({ children }: AppLayoutProps) { ) })} + + {/* Command Palette Button */} +
+ +
diff --git a/frontend/src/features/command-palette/CommandPalette.tsx b/frontend/src/features/command-palette/CommandPalette.tsx new file mode 100644 index 00000000..ab94ae3c --- /dev/null +++ b/frontend/src/features/command-palette/CommandPalette.tsx @@ -0,0 +1,502 @@ +import { useEffect, useState, useCallback, useMemo, useRef } from 'react' +import { useNavigate, useLocation } from 'react-router-dom' +import { Dialog, DialogContent } from '@/components/ui/dialog' +import { useCommandPaletteStore } from '@/store/commandPaletteStore' +import { useThemeStore } from '@/store/themeStore' +import { api } from '@/services/api' +import { cn } from '@/lib/utils' +import { + Workflow, + KeyRound, + Shield, + CalendarClock, + Archive, + Plug, + Plus, + Sun, + Moon, + Search, + ArrowRight, + Hash, + Command, + CornerDownLeft, +} from 'lucide-react' +import { env } from '@/config/env' +import type { WorkflowMetadataNormalized } from '@/schemas/workflow' +import { WorkflowMetadataSchema } from '@/schemas/workflow' + +// Command types +type CommandCategory = 'navigation' | 'workflows' | 'actions' | 'settings' + +interface BaseCommand { + id: string + label: string + description?: string + category: CommandCategory + icon?: React.ComponentType<{ className?: string }> + keywords?: string[] +} + +interface NavigationCommand extends BaseCommand { + type: 'navigation' + href: string +} + +interface ActionCommand extends BaseCommand { + type: 'action' + action: () => void +} + +interface WorkflowCommand extends BaseCommand { + type: 'workflow' + workflowId: string +} + +type Command = NavigationCommand | ActionCommand | WorkflowCommand + +// Category labels and order +const categoryLabels: Record = { + navigation: 'Navigation', + workflows: 'Workflows', + actions: 'Quick Actions', + settings: 'Settings', +} + +const categoryOrder: CommandCategory[] = ['actions', 'navigation', 'workflows', 'settings'] + +export function CommandPalette() { + const { isOpen, close } = useCommandPaletteStore() + const navigate = useNavigate() + const location = useLocation() + const { theme, startTransition } = useThemeStore() + const [query, setQuery] = useState('') + const [selectedIndex, setSelectedIndex] = useState(0) + const [workflows, setWorkflows] = useState([]) + const [isLoadingWorkflows, setIsLoadingWorkflows] = useState(false) + const inputRef = useRef(null) + const listRef = useRef(null) + + // Reset state when opening + useEffect(() => { + if (isOpen) { + setQuery('') + setSelectedIndex(0) + // Focus input after a short delay for animation + setTimeout(() => { + inputRef.current?.focus() + }, 50) + } + }, [isOpen]) + + // Load workflows when palette opens + useEffect(() => { + if (isOpen && workflows.length === 0) { + setIsLoadingWorkflows(true) + api.workflows + .list() + .then((data) => { + const normalized = data.map((w) => WorkflowMetadataSchema.parse(w)) + setWorkflows(normalized) + }) + .catch(() => { + // Silent fail - workflows just won't appear in search + }) + .finally(() => { + setIsLoadingWorkflows(false) + }) + } + }, [isOpen, workflows.length]) + + // Build static commands + const staticCommands = useMemo(() => { + const commands: Command[] = [ + // Quick Actions + { + id: 'new-workflow', + type: 'action', + label: 'Create New Workflow', + description: 'Start building a new automation workflow', + category: 'actions', + icon: Plus, + keywords: ['new', 'create', 'add', 'workflow'], + action: () => { + navigate('/workflows/new') + close() + }, + }, + { + id: 'toggle-theme', + type: 'action', + label: theme === 'dark' ? 'Switch to Light Mode' : 'Switch to Dark Mode', + description: 'Toggle between light and dark themes', + category: 'settings', + icon: theme === 'dark' ? Sun : Moon, + keywords: ['theme', 'dark', 'light', 'mode', 'toggle'], + action: () => { + startTransition() + close() + }, + }, + // Navigation + { + id: 'nav-workflows', + type: 'navigation', + label: 'Workflows', + description: 'View and manage your workflows', + category: 'navigation', + icon: Workflow, + keywords: ['workflows', 'list', 'home'], + href: '/', + }, + { + id: 'nav-schedules', + type: 'navigation', + label: 'Schedules', + description: 'Manage workflow schedules', + category: 'navigation', + icon: CalendarClock, + keywords: ['schedules', 'cron', 'timer', 'recurring'], + href: '/schedules', + }, + { + id: 'nav-secrets', + type: 'navigation', + label: 'Secrets', + description: 'Manage API keys and credentials', + category: 'navigation', + icon: KeyRound, + keywords: ['secrets', 'credentials', 'passwords', 'tokens'], + href: '/secrets', + }, + { + id: 'nav-api-keys', + type: 'navigation', + label: 'API Keys', + description: 'Manage your API keys', + category: 'navigation', + icon: Shield, + keywords: ['api', 'keys', 'authentication'], + href: '/api-keys', + }, + { + id: 'nav-artifacts', + type: 'navigation', + label: 'Artifact Library', + description: 'Browse stored artifacts', + category: 'navigation', + icon: Archive, + keywords: ['artifacts', 'files', 'storage', 'library'], + href: '/artifacts', + }, + ] + + // Add connections navigation if enabled + if (env.VITE_ENABLE_CONNECTIONS) { + commands.push({ + id: 'nav-connections', + type: 'navigation', + label: 'Connections', + description: 'Manage third-party integrations', + category: 'navigation', + icon: Plug, + keywords: ['connections', 'integrations', 'oauth'], + href: '/integrations', + }) + } + + return commands + }, [theme, navigate, close, startTransition]) + + // Build workflow commands + const workflowCommands = useMemo(() => { + return workflows.map((workflow) => ({ + id: `workflow-${workflow.id}`, + type: 'workflow' as const, + label: workflow.name, + description: `Open workflow · ${workflow.nodes.length} nodes`, + category: 'workflows' as const, + icon: Workflow, + workflowId: workflow.id, + keywords: [workflow.name.toLowerCase(), 'workflow', 'open'], + })) + }, [workflows]) + + // Combine all commands + const allCommands = useMemo(() => { + return [...staticCommands, ...workflowCommands] + }, [staticCommands, workflowCommands]) + + // Filter commands based on query + const filteredCommands = useMemo(() => { + if (!query.trim()) { + // Show all commands when no query, limited workflows + return [ + ...staticCommands, + ...workflowCommands.slice(0, 5), // Show first 5 workflows + ] + } + + const searchTerms = query.toLowerCase().split(' ').filter(Boolean) + + return allCommands.filter((cmd) => { + const searchableText = [ + cmd.label, + cmd.description || '', + ...(cmd.keywords || []), + ] + .join(' ') + .toLowerCase() + + return searchTerms.every((term) => searchableText.includes(term)) + }) + }, [query, allCommands, staticCommands, workflowCommands]) + + // Group commands by category + const groupedCommands = useMemo(() => { + const groups: Record = { + navigation: [], + workflows: [], + actions: [], + settings: [], + } + + filteredCommands.forEach((cmd) => { + groups[cmd.category].push(cmd) + }) + + // Return sorted by category order, filtering empty categories + return categoryOrder + .filter((cat) => groups[cat].length > 0) + .map((cat) => ({ + category: cat, + label: categoryLabels[cat], + commands: groups[cat], + })) + }, [filteredCommands]) + + // Flat list for keyboard navigation + const flatCommandList = useMemo(() => { + return groupedCommands.flatMap((group) => group.commands) + }, [groupedCommands]) + + // Clamp selected index + useEffect(() => { + if (selectedIndex >= flatCommandList.length) { + setSelectedIndex(Math.max(0, flatCommandList.length - 1)) + } + }, [flatCommandList.length, selectedIndex]) + + // Execute command + const executeCommand = useCallback( + (command: Command) => { + switch (command.type) { + case 'navigation': + navigate(command.href) + close() + break + case 'action': + command.action() + break + case 'workflow': + navigate(`/workflows/${command.workflowId}`) + close() + break + } + }, + [navigate, close] + ) + + // Keyboard handling + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + switch (e.key) { + case 'ArrowDown': + e.preventDefault() + setSelectedIndex((prev) => Math.min(prev + 1, flatCommandList.length - 1)) + break + case 'ArrowUp': + e.preventDefault() + setSelectedIndex((prev) => Math.max(prev - 1, 0)) + break + case 'Enter': + e.preventDefault() + if (flatCommandList[selectedIndex]) { + executeCommand(flatCommandList[selectedIndex]) + } + break + case 'Escape': + e.preventDefault() + close() + break + } + }, + [flatCommandList, selectedIndex, executeCommand, close] + ) + + // Scroll selected item into view + useEffect(() => { + const listEl = listRef.current + if (!listEl) return + + const selectedEl = listEl.querySelector('[data-selected="true"]') + if (selectedEl) { + selectedEl.scrollIntoView({ block: 'nearest' }) + } + }, [selectedIndex]) + + // Close on location change + useEffect(() => { + close() + }, [location.pathname, close]) + + return ( + !open && close()}> + { + e.preventDefault() + close() + }} + > + {/* Search input */} +
+ + { + setQuery(e.target.value) + setSelectedIndex(0) + }} + onKeyDown={handleKeyDown} + placeholder="Search commands, workflows, settings..." + className="flex-1 bg-transparent border-none outline-none text-base placeholder:text-muted-foreground/60" + autoComplete="off" + spellCheck={false} + /> + + ESC + +
+ + {/* Command list */} +
+ {isLoadingWorkflows && query && ( +
+ Loading workflows... +
+ )} + + {!isLoadingWorkflows && flatCommandList.length === 0 && ( +
+ +

No results found

+

+ Try a different search term +

+
+ )} + + {groupedCommands.map((group, groupIndex) => { + // Calculate starting index for this group + let startIndex = 0 + for (let i = 0; i < groupIndex; i++) { + startIndex += groupedCommands[i].commands.length + } + + return ( +
+
+ + {group.label} + +
+ {group.commands.map((command, cmdIndex) => { + const flatIndex = startIndex + cmdIndex + const isSelected = flatIndex === selectedIndex + const Icon = command.icon + + return ( + + ) + })} +
+ ) + })} +
+ + {/* Footer with keyboard hints */} +
+
+ + + ↑ + + + ↓ + + Navigate + + + + + + Select + +
+ + + ESC + + Close + +
+
+
+ ) +} diff --git a/frontend/src/features/command-palette/index.ts b/frontend/src/features/command-palette/index.ts new file mode 100644 index 00000000..9154d645 --- /dev/null +++ b/frontend/src/features/command-palette/index.ts @@ -0,0 +1,2 @@ +export { CommandPalette } from './CommandPalette' +export { useCommandPaletteKeyboard } from './useCommandPaletteKeyboard' diff --git a/frontend/src/features/command-palette/useCommandPaletteKeyboard.ts b/frontend/src/features/command-palette/useCommandPaletteKeyboard.ts new file mode 100644 index 00000000..e24dc209 --- /dev/null +++ b/frontend/src/features/command-palette/useCommandPaletteKeyboard.ts @@ -0,0 +1,36 @@ +import { useEffect } from 'react' +import { useCommandPaletteStore } from '@/store/commandPaletteStore' + +/** + * Hook to register global keyboard shortcut for Command Palette + * Should be used once at the app root level + */ +export function useCommandPaletteKeyboard() { + const { toggle, isOpen, close } = useCommandPaletteStore() + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Cmd+K (Mac) or Ctrl+K (Windows/Linux) + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault() + e.stopPropagation() + toggle() + return + } + + // Escape to close (backup, also handled in component) + if (e.key === 'Escape' && isOpen) { + e.preventDefault() + close() + return + } + } + + // Use capture phase to intercept before other handlers + document.addEventListener('keydown', handleKeyDown, { capture: true }) + + return () => { + document.removeEventListener('keydown', handleKeyDown, { capture: true }) + } + }, [toggle, isOpen, close]) +} diff --git a/frontend/src/store/commandPaletteStore.ts b/frontend/src/store/commandPaletteStore.ts new file mode 100644 index 00000000..f2e622bd --- /dev/null +++ b/frontend/src/store/commandPaletteStore.ts @@ -0,0 +1,28 @@ +import { create } from 'zustand' + +interface CommandPaletteStore { + isOpen: boolean + open: () => void + close: () => void + toggle: () => void +} + +/** + * Command Palette Store + * Manages the open/close state of the command palette + */ +export const useCommandPaletteStore = create((set) => ({ + isOpen: false, + + open: () => { + set({ isOpen: true }) + }, + + close: () => { + set({ isOpen: false }) + }, + + toggle: () => { + set((state) => ({ isOpen: !state.isOpen })) + }, +})) From 4302222001c556d41d933b1ba8e862aa50aa7492 Mon Sep 17 00:00:00 2001 From: betterclever Date: Sat, 27 Dec 2025 08:36:59 +0530 Subject: [PATCH 2/2] feat(command-palette): add component search with tap-to-place integration - Search and add components directly from Cmd+K menu - Components show with icons/logos and descriptions - Selecting a component triggers 'tap to place' mode on canvas - Components only shown when on a workflow page in design mode - Graceful handling when not on workflow (navigates to new workflow) Signed-off-by: betterclever --- .../command-palette/CommandPalette.tsx | 200 +++++++++++++++--- 1 file changed, 176 insertions(+), 24 deletions(-) diff --git a/frontend/src/features/command-palette/CommandPalette.tsx b/frontend/src/features/command-palette/CommandPalette.tsx index ab94ae3c..99e364a6 100644 --- a/frontend/src/features/command-palette/CommandPalette.tsx +++ b/frontend/src/features/command-palette/CommandPalette.tsx @@ -1,8 +1,12 @@ import { useEffect, useState, useCallback, useMemo, useRef } from 'react' import { useNavigate, useLocation } from 'react-router-dom' +import * as LucideIcons from 'lucide-react' import { Dialog, DialogContent } from '@/components/ui/dialog' import { useCommandPaletteStore } from '@/store/commandPaletteStore' import { useThemeStore } from '@/store/themeStore' +import { useComponentStore } from '@/store/componentStore' +import { useWorkflowUiStore } from '@/store/workflowUiStore' +import { mobilePlacementState } from '@/components/layout/Sidebar' import { api } from '@/services/api' import { cn } from '@/lib/utils' import { @@ -20,13 +24,16 @@ import { Hash, Command, CornerDownLeft, + Box, + Puzzle, } from 'lucide-react' import { env } from '@/config/env' import type { WorkflowMetadataNormalized } from '@/schemas/workflow' import { WorkflowMetadataSchema } from '@/schemas/workflow' +import type { ComponentMetadata } from '@/schemas/component' // Command types -type CommandCategory = 'navigation' | 'workflows' | 'actions' | 'settings' +type CommandCategory = 'navigation' | 'workflows' | 'actions' | 'settings' | 'components' interface BaseCommand { id: string @@ -34,6 +41,7 @@ interface BaseCommand { description?: string category: CommandCategory icon?: React.ComponentType<{ className?: string }> + iconUrl?: string // For component logos keywords?: string[] } @@ -52,7 +60,13 @@ interface WorkflowCommand extends BaseCommand { workflowId: string } -type Command = NavigationCommand | ActionCommand | WorkflowCommand +interface ComponentCommand extends BaseCommand { + type: 'component' + componentId: string + componentName: string +} + +type Command = NavigationCommand | ActionCommand | WorkflowCommand | ComponentCommand // Category labels and order const categoryLabels: Record = { @@ -60,15 +74,18 @@ const categoryLabels: Record = { workflows: 'Workflows', actions: 'Quick Actions', settings: 'Settings', + components: 'Add Component', } -const categoryOrder: CommandCategory[] = ['actions', 'navigation', 'workflows', 'settings'] +const categoryOrder: CommandCategory[] = ['actions', 'components', 'navigation', 'workflows', 'settings'] export function CommandPalette() { const { isOpen, close } = useCommandPaletteStore() const navigate = useNavigate() const location = useLocation() const { theme, startTransition } = useThemeStore() + const { getAllComponents, fetchComponents, loading: componentsLoading } = useComponentStore() + const mode = useWorkflowUiStore((state) => state.mode) const [query, setQuery] = useState('') const [selectedIndex, setSelectedIndex] = useState(0) const [workflows, setWorkflows] = useState([]) @@ -76,6 +93,11 @@ export function CommandPalette() { const inputRef = useRef(null) const listRef = useRef(null) + // Check if we're on a workflow builder page + const isOnWorkflowPage = location.pathname.startsWith('/workflows/') && location.pathname !== '/workflows/new' + const isOnNewWorkflowPage = location.pathname === '/workflows/new' + const canPlaceComponents = (isOnWorkflowPage || isOnNewWorkflowPage) && mode === 'design' + // Reset state when opening useEffect(() => { if (isOpen) { @@ -88,7 +110,7 @@ export function CommandPalette() { } }, [isOpen]) - // Load workflows when palette opens + // Load workflows and components when palette opens useEffect(() => { if (isOpen && workflows.length === 0) { setIsLoadingWorkflows(true) @@ -105,7 +127,72 @@ export function CommandPalette() { setIsLoadingWorkflows(false) }) } - }, [isOpen, workflows.length]) + + // Fetch components if not already loaded + if (isOpen && getAllComponents().length === 0) { + fetchComponents().catch(() => { + // Silent fail + }) + } + }, [isOpen, workflows.length, getAllComponents, fetchComponents]) + + // Get all components (filtered same as Sidebar) + const allComponents = useMemo(() => { + const components = getAllComponents() + return components.filter((component) => { + // Filter out entry point + if (component.id === 'core.workflow.entrypoint' || component.slug === 'entry-point') { + return false + } + // Filter out IT ops if disabled + if (!env.VITE_ENABLE_IT_OPS && component.category === 'it_ops') { + return false + } + // Filter out demo components + const name = component.name.toLowerCase() + const slug = component.slug.toLowerCase() + const category = component.category.toLowerCase() + if ( + category === 'demo' || + name.includes('demo') || + slug.includes('demo') || + name.includes('(test)') || + name === 'live event' || + name === 'parallel sleep' || + slug === 'live-event' || + slug === 'parallel-sleep' + ) { + return false + } + return true + }) + }, [getAllComponents]) + + // Handle component placement + const handleComponentSelect = useCallback( + (component: ComponentMetadata) => { + // If not on workflow page, navigate to new workflow first + if (!isOnWorkflowPage && !isOnNewWorkflowPage) { + // Navigate to new workflow - user will need to add component after + navigate('/workflows/new') + close() + return + } + + // Set up mobile placement state (works for both mobile and desktop now) + // The canvas will detect this and place the component + mobilePlacementState.componentId = component.id + mobilePlacementState.componentName = component.name + mobilePlacementState.isActive = true + + // Close the command palette + close() + + // For desktop, we could also trigger a center placement, but the "click to place" + // gives users more control over where the component goes + }, + [isOnWorkflowPage, isOnNewWorkflowPage, navigate, close] + ) // Build static commands const staticCommands = useMemo(() => { @@ -221,17 +308,50 @@ export function CommandPalette() { })) }, [workflows]) + // Build component commands + const componentCommands = useMemo(() => { + return allComponents.map((component) => { + // Get icon component if available + const iconName = component.icon && component.icon in LucideIcons ? component.icon : null + const IconComponent = iconName + ? (LucideIcons[iconName as keyof typeof LucideIcons] as React.ComponentType<{ className?: string }>) + : Box + + return { + id: `component-${component.id}`, + type: 'component' as const, + label: component.name, + description: component.description || `Add ${component.name} to canvas`, + category: 'components' as const, + icon: IconComponent, + iconUrl: component.logo || undefined, + componentId: component.id, + componentName: component.name, + keywords: [ + component.name.toLowerCase(), + component.slug.toLowerCase(), + component.category.toLowerCase(), + 'component', + 'add', + 'node', + ...(component.description?.toLowerCase().split(' ') || []), + ], + } + }) + }, [allComponents]) + // Combine all commands const allCommands = useMemo(() => { - return [...staticCommands, ...workflowCommands] - }, [staticCommands, workflowCommands]) + return [...staticCommands, ...workflowCommands, ...componentCommands] + }, [staticCommands, workflowCommands, componentCommands]) // Filter commands based on query const filteredCommands = useMemo(() => { if (!query.trim()) { - // Show all commands when no query, limited workflows + // Show all static commands and limited workflows/components when no query return [ ...staticCommands, + ...(canPlaceComponents ? componentCommands.slice(0, 5) : []), // Show first 5 components if on workflow page ...workflowCommands.slice(0, 5), // Show first 5 workflows ] } @@ -249,7 +369,7 @@ export function CommandPalette() { return searchTerms.every((term) => searchableText.includes(term)) }) - }, [query, allCommands, staticCommands, workflowCommands]) + }, [query, allCommands, staticCommands, workflowCommands, componentCommands, canPlaceComponents]) // Group commands by category const groupedCommands = useMemo(() => { @@ -258,6 +378,7 @@ export function CommandPalette() { workflows: [], actions: [], settings: [], + components: [], } filteredCommands.forEach((cmd) => { @@ -301,9 +422,16 @@ export function CommandPalette() { navigate(`/workflows/${command.workflowId}`) close() break + case 'component': { + const component = allComponents.find((c) => c.id === command.componentId) + if (component) { + handleComponentSelect(component) + } + break + } } }, - [navigate, close] + [navigate, close, allComponents, handleComponentSelect] ) // Keyboard handling @@ -371,7 +499,7 @@ export function CommandPalette() { setSelectedIndex(0) }} onKeyDown={handleKeyDown} - placeholder="Search commands, workflows, settings..." + placeholder={canPlaceComponents ? "Search commands, components, workflows..." : "Search commands, workflows, settings..."} className="flex-1 bg-transparent border-none outline-none text-base placeholder:text-muted-foreground/60" autoComplete="off" spellCheck={false} @@ -386,13 +514,13 @@ export function CommandPalette() { ref={listRef} className="max-h-[400px] overflow-y-auto overflow-x-hidden" > - {isLoadingWorkflows && query && ( + {(isLoadingWorkflows || componentsLoading) && query && (
- Loading workflows... + Loading...
)} - {!isLoadingWorkflows && flatCommandList.length === 0 && ( + {!isLoadingWorkflows && !componentsLoading && flatCommandList.length === 0 && (

No results found

@@ -411,15 +539,25 @@ export function CommandPalette() { return (
-
+
+ {group.category === 'components' && ( + + )} {group.label} + {group.category === 'components' && !canPlaceComponents && ( + + (open a workflow first) + + )}
{group.commands.map((command, cmdIndex) => { const flatIndex = startIndex + cmdIndex const isSelected = flatIndex === selectedIndex const Icon = command.icon + const isComponent = command.type === 'component' + const hasLogo = isComponent && 'iconUrl' in command && command.iconUrl return (