import { useState, useEffect, useRef, useCallback, useMemo } from 'react' import { useLocation, useNavigate } from 'react-router-dom' import { PILOT_INLINE_SCRIPT_EVENT } from '@/lib/pilotEvents' import { Search, Loader2, ArrowRight, FileText, Clock, Sparkles, LayoutDashboard, Tag, Plus, BookOpen, Terminal, Zap, } from 'lucide-react' import { treesApi } from '@/api/trees' import { sessionsApi } from '@/api/sessions' import { aiSessionsApi } from '@/api/aiSessions' import type { TreeListItem } from '@/types' import type { Session } from '@/types/session' import type { AISessionSearchResult } from '@/types/ai-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 } type GroupType = 'flowpilot' | 'pages' | 'flows' | 'sessions' | 'ai-sessions' | 'tags' | 'quick-actions' | 'recent-flows' interface PaletteItem { id: string group: GroupType title: string subtitle?: string path: string icon: 'sparkles' | 'tree' | 'session' | 'ai-session' | 'page' | 'tag' | 'action' | 'recent' } interface Group { type: GroupType label: string items: PaletteItem[] } const PAGES: PaletteItem[] = [ { id: 'page-dashboard', group: 'pages', title: 'Dashboard', path: '/home', 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-flowpilot', group: 'pages', title: 'FlowPilot', subtitle: 'AI troubleshooting', path: '/pilot', 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: 'Solutions Library', subtitle: 'Team solutions', path: '/step-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-new-project', group: 'quick-actions', title: 'New Project', subtitle: 'Create a step-by-step project', path: '/flows/new', 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' }, ] // Phase 5: only surfaced when on a /pilot/:id route. Fires the inline-script // open event instead of navigating away to /scripts. The path is a sentinel // — handleSelect intercepts it and dispatches a window event rather than // navigating, so the chat page can toggle its inline panel without coupling // the global palette to chat-page state. const PILOT_INLINE_SCRIPT_PATH = '__pilot_inline_script__' const SCRIPTS_INLINE_QUICK_ACTION: PaletteItem = { id: 'action-scripts-inline', group: 'quick-actions', title: 'Open inline Script Generator', subtitle: 'For the active suggested fix in this session', path: PILOT_INLINE_SCRIPT_PATH, 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 'ai-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 location = useLocation() const user = useAuthStore(s => s.user) // True when the user is currently on a FlowPilot session deep-link. // Used to surface the "Open inline Script Generator" palette entry only // when it's actually actionable (the chat page listens for the event; // dispatching it from /trees would do nothing). const onPilotSession = location.pathname.startsWith('/pilot/') const inputRef = useRef(null) const [query, setQuery] = useState('') const [isSearching, setIsSearching] = useState(false) const [selectedIndex, setSelectedIndex] = useState(0) const [searchFlows, setSearchFlows] = useState([]) const [searchSessions, setSearchSessions] = useState([]) const [searchAISessions, setSearchAISessions] = useState([]) const debounceRef = useRef | null>(null) // Focus input when opened useEffect(() => { if (open) { setQuery('') setSearchFlows([]) setSearchSessions([]) setSearchAISessions([]) setSelectedIndex(0) setTimeout(() => inputRef.current?.focus(), 50) } }, [open]) // Close on Escape useEffect(() => { if (!open) return const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() } document.addEventListener('keydown', handler) return () => document.removeEventListener('keydown', handler) }, [open, onClose]) // Debounced search useEffect(() => { if (debounceRef.current) clearTimeout(debounceRef.current) if (query.trim().length < 2) { setSearchFlows([]) setSearchSessions([]) setSearchAISessions([]) setIsSearching(false) return } setIsSearching(true) debounceRef.current = setTimeout(async () => { try { const [flows, sessions, aiSessions] = await Promise.all([ treesApi.search(query, 6), sessionsApi.list({ size: 5 }).catch(() => [] as Session[]), aiSessionsApi.search(query, 5).catch(() => [] as AISessionSearchResult[]), ]) 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) setSearchAISessions(aiSessions) } catch { setSearchFlows([]) setSearchSessions([]) setSearchAISessions([]) } finally { setIsSearching(false) } }, 250) return () => { if (debounceRef.current) clearTimeout(debounceRef.current) } }, [query]) // Build groups based on intent and search results const builtGroups = useMemo((): 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 }) } const quickActions = onPilotSession ? [SCRIPTS_INLINE_QUICK_ACTION, ...QUICK_ACTIONS] : QUICK_ACTIONS result.push({ type: 'quick-actions', label: 'Quick Actions', items: quickActions }) return result } // Build FlowPilot item const flowPilotItem: PaletteItem = { id: 'flowpilot-ai', group: 'flowpilot', title: 'Troubleshoot with FlowPilot', subtitle: trimmed, path: '/pilot', 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, })) // Build AI session items const aiSessionItems: PaletteItem[] = searchAISessions.map(s => { const title = s.problem_summary ? s.problem_summary.slice(0, 60) + (s.problem_summary.length > 60 ? '…' : '') : 'FlowPilot Session' const statusLabel = s.status === 'resolved' ? 'Resolved' : s.status === 'escalated' ? 'Escalated' : 'Active' const subtitle = [s.problem_domain, statusLabel].filter(Boolean).join(' · ') return { id: `ai-session-${s.id}`, group: 'ai-sessions' as GroupType, title, subtitle: subtitle || undefined, path: `/pilot/${s.id}`, icon: 'ai-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 (aiSessionItems.length > 0) result.push({ type: 'ai-sessions', label: 'FlowPilot Sessions', items: aiSessionItems }) 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 (aiSessionItems.length > 0) result.push({ type: 'ai-sessions', label: 'FlowPilot Sessions', items: aiSessionItems }) 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 (aiSessionItems.length > 0) result.push({ type: 'ai-sessions', label: 'FlowPilot Sessions', items: aiSessionItems }) 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, searchAISessions, user, onPilotSession]) // Flatten all items for keyboard navigation const flatItems: PaletteItem[] = builtGroups.flatMap(g => g.items) const handleSelect = useCallback((item: PaletteItem) => { onClose() if (item.path === PILOT_INLINE_SCRIPT_PATH) { // Phase 5: window event lets the chat page open the inline panel // without coupling the global palette to chat-page state. window.dispatchEvent(new CustomEvent(PILOT_INLINE_SCRIPT_EVENT)) return } if (item.group === 'flowpilot') { navigate(item.path, { state: { prefill: query.trim() } }) } else { navigate(item.path) } }, [navigate, onClose, query]) const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'ArrowDown') { e.preventDefault() 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' && flatItems[selectedIndex]) { e.preventDefault() 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 */}
{/* Palette */}
{/* Search input */}
{ setQuery(e.target.value); setSelectedIndex(0) }} onKeyDown={handleKeyDown} placeholder="Search flows, sessions, tags... or describe an issue to troubleshoot" className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-hidden" /> ESC
{/* Results */}
{isSearching ? (
) : hasQuery && flatItems.length === 0 ? (
No results for “{query}”
) : builtGroups.length > 0 ? (
{builtGroups.map(group => { const groupStart = globalIdx globalIdx += group.items.length return (
{/* Section label */}
{group.label}
{group.items.map((item, i) => { const itemGlobalIdx = groupStart + i const isSelected = itemGlobalIdx === selectedIndex const isFlowPilot = item.group === 'flowpilot' if (isFlowPilot) { // Special prominent styling for question intent at top return ( ) } return ( ) })}
) })}
) : (
{isEmpty ? 'Type to search flows, pages, or ask FlowPilot a question' : 'Type to search flows and sessions'}
)}
{/* Footer hints */} {flatItems.length > 0 && (
↑↓ Navigate Open
)}
) }