diff --git a/frontend/src/components/layout/CommandPalette.tsx b/frontend/src/components/layout/CommandPalette.tsx index 0b9bb7cf..73110415 100644 --- a/frontend/src/components/layout/CommandPalette.tsx +++ b/frontend/src/components/layout/CommandPalette.tsx @@ -1,43 +1,94 @@ import { useState, useEffect, useRef, useCallback } from 'react' import { useNavigate } from 'react-router-dom' -import { Search, Loader2, ArrowRight, FileText, Clock } from 'lucide-react' +import { + Search, Loader2, ArrowRight, FileText, Clock, + Sparkles, LayoutDashboard, Tag, Plus, BookOpen, Terminal, +} from 'lucide-react' import { treesApi } from '@/api/trees' import { sessionsApi } from '@/api/sessions' import type { TreeListItem } from '@/types' import type { Session } from '@/types/session' import { getTreeNavigatePath } from '@/lib/routing' import { cn } from '@/lib/utils' +import { detectIntent } from '@/lib/paletteIntent' +import { getRecentFlows } from '@/lib/recentFlows' +import { useAuthStore } from '@/store/authStore' interface CommandPaletteProps { open: boolean onClose: () => void } -interface ResultItem { +type GroupType = 'flowpilot' | 'pages' | 'flows' | 'sessions' | 'tags' | 'quick-actions' | 'recent-flows' + +interface PaletteItem { id: string - type: 'tree' | 'session' + group: GroupType title: string subtitle?: string - icon: 'tree' | 'session' path: string + icon: 'sparkles' | 'tree' | 'session' | 'page' | 'tag' | 'action' | 'recent' +} + +interface Group { + type: GroupType + label: string + items: PaletteItem[] +} + +const PAGES: PaletteItem[] = [ + { id: 'page-dashboard', group: 'pages', title: 'Dashboard', path: '/', icon: 'page' }, + { id: 'page-flows', group: 'pages', title: 'All Flows', subtitle: 'Browse your flow library', path: '/trees', icon: 'page' }, + { id: 'page-sessions', group: 'pages', title: 'Sessions', subtitle: 'View session history', path: '/sessions', icon: 'page' }, + { id: 'page-assistant', group: 'pages', title: 'AI Assistant', subtitle: 'FlowPilot chat', path: '/assistant', icon: 'page' }, + { id: 'page-scripts', group: 'pages', title: 'Script Generator', subtitle: 'Generate PowerShell scripts', path: '/scripts', icon: 'page' }, + { id: 'page-analytics', group: 'pages', title: 'Analytics', subtitle: 'Team usage & metrics', path: '/analytics', icon: 'page' }, + { id: 'page-settings', group: 'pages', title: 'Settings', subtitle: 'Account & preferences', path: '/account', icon: 'page' }, + { id: 'page-library', group: 'pages', title: 'Step Library', subtitle: 'Reusable steps', path: '/library', icon: 'page' }, +] + +const ADMIN_PAGES: PaletteItem[] = [ + { id: 'page-admin', group: 'pages', title: 'Admin', subtitle: 'Platform administration', path: '/admin', icon: 'page' }, +] + +const QUICK_ACTIONS: PaletteItem[] = [ + { id: 'action-new-flow', group: 'quick-actions', title: 'Create New Flow', subtitle: 'Start from scratch or use AI', path: '/trees', icon: 'action' }, + { id: 'action-kb', group: 'quick-actions', title: 'Import from KB', subtitle: 'KB Accelerator', path: '/kb-accelerator', icon: 'action' }, + { id: 'action-scripts', group: 'quick-actions', title: 'Open Script Generator', subtitle: 'Generate automation scripts', path: '/scripts', icon: 'action' }, +] + +function ItemIcon({ icon, className }: { icon: PaletteItem['icon'], className?: string }) { + const cls = cn('shrink-0', className) + switch (icon) { + case 'sparkles': return + case 'tree': return + case 'session': return + case 'page': return + case 'tag': return + case 'action': return + case 'recent': return + default: return + } } export function CommandPalette({ open, onClose }: CommandPaletteProps) { const navigate = useNavigate() + const user = useAuthStore(s => s.user) const inputRef = useRef(null) const [query, setQuery] = useState('') - const [results, setResults] = useState([]) const [isSearching, setIsSearching] = useState(false) const [selectedIndex, setSelectedIndex] = useState(0) + const [searchFlows, setSearchFlows] = useState([]) + const [searchSessions, setSearchSessions] = useState([]) const debounceRef = useRef | null>(null) // Focus input when opened useEffect(() => { if (open) { setQuery('') - setResults([]) + setSearchFlows([]) + setSearchSessions([]) setSelectedIndex(0) - // Slight delay to ensure modal is rendered setTimeout(() => inputRef.current?.focus(), 50) } }, [open]) @@ -55,46 +106,28 @@ export function CommandPalette({ open, onClose }: CommandPaletteProps) { // Debounced search useEffect(() => { if (debounceRef.current) clearTimeout(debounceRef.current) - if (query.length < 2) { - setResults([]) + if (query.trim().length < 2) { + setSearchFlows([]) + setSearchSessions([]) setIsSearching(false) return } setIsSearching(true) debounceRef.current = setTimeout(async () => { try { - const [trees, sessions] = await Promise.all([ + const [flows, sessions] = await Promise.all([ treesApi.search(query, 6), sessionsApi.list({ size: 5 }).catch(() => [] as Session[]), ]) - - const treeResults: ResultItem[] = trees.map((t: TreeListItem) => ({ - id: t.id, - type: 'tree' as const, - title: t.name, - subtitle: t.description || undefined, - icon: 'tree' as const, - path: getTreeNavigatePath(t.id, t.tree_type), - })) - - // Filter sessions by tree name matching query - const sessionResults: ResultItem[] = sessions - .filter((s: Session) => - s.tree_snapshot?.name?.toLowerCase().includes(query.toLowerCase()) - ) - .slice(0, 3) - .map((s: Session) => ({ - id: s.id, - type: 'session' as const, - title: s.tree_snapshot?.name || 'Session', - subtitle: s.completed_at ? 'Completed' : 'In progress', - icon: 'session' as const, - path: `/sessions/${s.id}`, - })) - - setResults([...treeResults, ...sessionResults]) + setSearchFlows(flows) + // Filter sessions by tree name + const filtered = sessions.filter((s: Session) => + s.tree_snapshot?.name?.toLowerCase().includes(query.toLowerCase()) + ).slice(0, 3) + setSearchSessions(filtered) } catch { - setResults([]) + setSearchFlows([]) + setSearchSessions([]) } finally { setIsSearching(false) } @@ -102,29 +135,153 @@ export function CommandPalette({ open, onClose }: CommandPaletteProps) { return () => { if (debounceRef.current) clearTimeout(debounceRef.current) } }, [query]) - const handleSelect = useCallback((item: ResultItem) => { - onClose() - navigate(item.path) - }, [navigate, onClose]) + // Build groups based on intent and search results + const groups = useCallback((): Group[] => { + const trimmed = query.trim() + const intent = detectIntent(trimmed) + const lower = trimmed.toLowerCase() + + if (intent === 'empty') { + // Empty state: recent flows + quick actions + const recentFlows = getRecentFlows(5) + const recentItems: PaletteItem[] = recentFlows.map(f => ({ + id: `recent-${f.id}`, + group: 'recent-flows' as GroupType, + title: f.name, + subtitle: f.tree_type, + path: getTreeNavigatePath(f.id, f.tree_type), + icon: 'recent' as const, + })) + + const result: Group[] = [] + if (recentItems.length > 0) { + result.push({ type: 'recent-flows', label: 'Recent Flows', items: recentItems }) + } + result.push({ type: 'quick-actions', label: 'Quick Actions', items: QUICK_ACTIONS }) + return result + } + + // Build FlowPilot item + const flowPilotItem: PaletteItem = { + id: 'flowpilot-ai', + group: 'flowpilot', + title: 'Ask FlowPilot AI', + subtitle: trimmed, + path: '/assistant', + icon: 'sparkles', + } + + // Filter pages + const allPages = user?.is_super_admin ? [...PAGES, ...ADMIN_PAGES] : PAGES + const filteredPages = allPages.filter(p => + p.title.toLowerCase().includes(lower) || + (p.subtitle?.toLowerCase().includes(lower) ?? false) + ) + + // Build flow items + const flowItems: PaletteItem[] = searchFlows.map(f => ({ + id: `flow-${f.id}`, + group: 'flows' as GroupType, + title: f.name, + subtitle: f.description || undefined, + path: getTreeNavigatePath(f.id, f.tree_type), + icon: 'tree' as const, + })) + + // Extract unique tags from search results + const tagSet = new Set() + for (const f of searchFlows) { + if (Array.isArray(f.tags)) { + for (const t of f.tags) { + if (t.toLowerCase().includes(lower)) tagSet.add(t) + } + } + } + const tagItems: PaletteItem[] = Array.from(tagSet).slice(0, 4).map(tag => ({ + id: `tag-${tag}`, + group: 'tags' as GroupType, + title: tag, + subtitle: 'Browse flows with this tag', + path: `/trees?tag=${encodeURIComponent(tag)}`, + icon: 'tag' as const, + })) + + // Build session items + const sessionItems: PaletteItem[] = searchSessions.map(s => ({ + id: `session-${s.id}`, + group: 'sessions' as GroupType, + title: s.tree_snapshot?.name || 'Session', + subtitle: s.completed_at ? 'Completed' : 'In progress', + path: `/sessions/${s.id}`, + icon: 'session' as const, + })) + + const result: Group[] = [] + + if (intent === 'question') { + // FlowPilot prominent at top + result.push({ type: 'flowpilot', label: 'AI Assistant', items: [flowPilotItem] }) + if (flowItems.length > 0) result.push({ type: 'flows', label: 'Flows', items: flowItems }) + if (sessionItems.length > 0) result.push({ type: 'sessions', label: 'Sessions', items: sessionItems }) + if (tagItems.length > 0) result.push({ type: 'tags', label: 'Tags', items: tagItems }) + } else if (intent === 'page') { + // Pages first, FlowPilot at bottom + if (filteredPages.length > 0) result.push({ type: 'pages', label: 'Pages', items: filteredPages }) + if (flowItems.length > 0) result.push({ type: 'flows', label: 'Flows', items: flowItems }) + if (sessionItems.length > 0) result.push({ type: 'sessions', label: 'Sessions', items: sessionItems }) + if (tagItems.length > 0) result.push({ type: 'tags', label: 'Tags', items: tagItems }) + result.push({ type: 'flowpilot', label: 'AI Assistant', items: [flowPilotItem] }) + } else { + // keyword: FlowPilot at top, flows/sessions/tags below + result.push({ type: 'flowpilot', label: 'AI Assistant', items: [flowPilotItem] }) + if (flowItems.length > 0) result.push({ type: 'flows', label: 'Flows', items: flowItems }) + if (sessionItems.length > 0) result.push({ type: 'sessions', label: 'Sessions', items: sessionItems }) + if (tagItems.length > 0) result.push({ type: 'tags', label: 'Tags', items: tagItems }) + if (filteredPages.length > 0) result.push({ type: 'pages', label: 'Pages', items: filteredPages }) + } + + return result + }, [query, searchFlows, searchSessions, user]) + + const builtGroups = groups() + + // Flatten all items for keyboard navigation + const flatItems: PaletteItem[] = builtGroups.flatMap(g => g.items) + + const handleSelect = useCallback((item: PaletteItem) => { + onClose() + if (item.group === 'flowpilot') { + navigate(item.path, { state: { prefill: query.trim() } }) + } else { + navigate(item.path) + } + }, [navigate, onClose, query]) - // Keyboard navigation const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'ArrowDown') { e.preventDefault() - setSelectedIndex(i => Math.min(i + 1, results.length - 1)) + setSelectedIndex(i => Math.min(i + 1, flatItems.length - 1)) } else if (e.key === 'ArrowUp') { e.preventDefault() setSelectedIndex(i => Math.max(i - 1, 0)) - } else if (e.key === 'Enter' && results[selectedIndex]) { + } else if (e.key === 'Enter' && flatItems[selectedIndex]) { e.preventDefault() - handleSelect(results[selectedIndex]) + handleSelect(flatItems[selectedIndex]) } } + // Track global flat index for selection highlight + let globalIdx = 0 + + const intent = detectIntent(query.trim()) + const hasQuery = query.trim().length >= 2 + const isEmpty = intent === 'empty' + const isQuestion = intent === 'question' + if (!open) return null return ( -
+
{/* Backdrop */}
{ setQuery(e.target.value); setSelectedIndex(0) }} onKeyDown={handleKeyDown} - placeholder="Search flows, sessions…" + placeholder="Search flows, ask a question, navigate…" className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-hidden" /> @@ -151,55 +308,120 @@ export function CommandPalette({ open, onClose }: CommandPaletteProps) {
{/* Results */} -
+
{isSearching ? (
- ) : query.length >= 2 && results.length === 0 ? ( + ) : hasQuery && flatItems.length === 0 ? (
No results for “{query}”
- ) : results.length > 0 ? ( + ) : builtGroups.length > 0 ? (
- {results.map((item, i) => ( - + ) + } + + return ( + + ) + })}
- {i === selectedIndex && ( - - )} - - ))} + ) + })}
) : (
- Type to search flows and sessions + {isEmpty + ? 'Type to search flows, pages, or ask FlowPilot a question' + : 'Type to search flows and sessions'}
)}
{/* Footer hints */} - {results.length > 0 && ( + {flatItems.length > 0 && (
↑↓