500 lines
21 KiB
TypeScript
500 lines
21 KiB
TypeScript
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: '/', 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 <Sparkles size={16} className={cls} />
|
|
case 'tree': return <FileText size={16} className={cls} />
|
|
case 'session': return <Clock size={16} className={cls} />
|
|
case 'ai-session': return <Zap size={16} className={cls} />
|
|
case 'page': return <LayoutDashboard size={16} className={cls} />
|
|
case 'tag': return <Tag size={16} className={cls} />
|
|
case 'action': return <Plus size={16} className={cls} />
|
|
case 'recent': return <BookOpen size={16} className={cls} />
|
|
default: return <Terminal size={16} className={cls} />
|
|
}
|
|
}
|
|
|
|
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<HTMLInputElement>(null)
|
|
const [query, setQuery] = useState('')
|
|
const [isSearching, setIsSearching] = useState(false)
|
|
const [selectedIndex, setSelectedIndex] = useState(0)
|
|
const [searchFlows, setSearchFlows] = useState<TreeListItem[]>([])
|
|
const [searchSessions, setSearchSessions] = useState<Session[]>([])
|
|
const [searchAISessions, setSearchAISessions] = useState<AISessionSearchResult[]>([])
|
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | 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<string>()
|
|
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 (
|
|
<div className="fixed inset-0 z-[100] flex items-start justify-center pt-[20vh]">
|
|
{/* Backdrop */}
|
|
<div
|
|
className="absolute inset-0 bg-black/60 backdrop-blur-xs animate-fade-in"
|
|
onClick={onClose}
|
|
/>
|
|
|
|
{/* Palette */}
|
|
<div className="relative w-full max-w-lg rounded-xl border border-border bg-card shadow-2xl animate-scale-in">
|
|
{/* Search input */}
|
|
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
|
|
<Search size={18} className="shrink-0 text-muted-foreground" />
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
value={query}
|
|
onChange={e => { 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"
|
|
/>
|
|
<kbd className="rounded border border-border bg-background px-1.5 py-0.5 font-mono text-[0.625rem] text-muted-foreground">
|
|
ESC
|
|
</kbd>
|
|
</div>
|
|
|
|
{/* Results */}
|
|
<div className="max-h-[28rem] overflow-y-auto">
|
|
{isSearching ? (
|
|
<div className="flex items-center justify-center py-8">
|
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
|
</div>
|
|
) : hasQuery && flatItems.length === 0 ? (
|
|
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
|
|
No results for “{query}”
|
|
</div>
|
|
) : builtGroups.length > 0 ? (
|
|
<div className="p-1">
|
|
{builtGroups.map(group => {
|
|
const groupStart = globalIdx
|
|
globalIdx += group.items.length
|
|
|
|
return (
|
|
<div key={group.type}>
|
|
{/* Section label */}
|
|
<div className="px-3 pt-3 pb-1">
|
|
<span className="font-mono text-[0.6875rem] uppercase tracking-[0.12em] text-[#fbbf24]">
|
|
{group.label}
|
|
</span>
|
|
</div>
|
|
|
|
{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 (
|
|
<button
|
|
key={item.id}
|
|
data-testid={item.id === 'flowpilot' ? 'command-palette-flowpilot' : undefined}
|
|
onClick={() => handleSelect(item)}
|
|
onMouseEnter={() => setSelectedIndex(itemGlobalIdx)}
|
|
className={cn(
|
|
'flex w-full items-center gap-3 rounded-[10px] px-3 py-2.5 text-left transition-colors',
|
|
'bg-primary/5 border border-primary/10',
|
|
isQuestion ? 'mb-1' : '',
|
|
isSelected
|
|
? 'bg-primary/15 border-primary/30'
|
|
: 'hover:bg-primary/10 hover:border-primary/20'
|
|
)}
|
|
>
|
|
<div className={cn(
|
|
'flex h-7 w-7 shrink-0 items-center justify-center rounded-lg',
|
|
isQuestion ? 'bg-primary/15' : 'bg-primary/10'
|
|
)}>
|
|
<Sparkles size={14} className="text-primary" />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className={cn(
|
|
'text-sm font-medium truncate',
|
|
isQuestion ? 'text-primary' : 'text-foreground'
|
|
)}>
|
|
{item.title}
|
|
</p>
|
|
{item.subtitle && (
|
|
<p className="text-[0.6875rem] text-muted-foreground truncate italic">
|
|
“{item.subtitle}”
|
|
</p>
|
|
)}
|
|
</div>
|
|
{isSelected && (
|
|
<ArrowRight size={14} className="shrink-0 text-primary opacity-60" />
|
|
)}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<button
|
|
key={item.id}
|
|
onClick={() => handleSelect(item)}
|
|
onMouseEnter={() => setSelectedIndex(itemGlobalIdx)}
|
|
className={cn(
|
|
'flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left border transition-colors',
|
|
isSelected
|
|
? 'bg-card-hover border-border-hover text-foreground'
|
|
: 'border-transparent text-muted-foreground hover:bg-card-hover hover:border-border-hover hover:text-foreground'
|
|
)}
|
|
>
|
|
<ItemIcon
|
|
icon={item.icon}
|
|
className={isSelected ? 'opacity-80' : 'opacity-50'}
|
|
/>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-sm font-medium truncate">{item.title}</p>
|
|
{item.subtitle && (
|
|
<p className="text-[0.6875rem] text-muted-foreground truncate">{item.subtitle}</p>
|
|
)}
|
|
</div>
|
|
{isSelected && (
|
|
<ArrowRight size={14} className="shrink-0 opacity-40" />
|
|
)}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
) : (
|
|
<div className="px-4 py-6 text-center text-sm text-muted-foreground">
|
|
{isEmpty
|
|
? 'Type to search flows, pages, or ask FlowPilot a question'
|
|
: 'Type to search flows and sessions'}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer hints */}
|
|
{flatItems.length > 0 && (
|
|
<div className="flex items-center gap-4 border-t border-border px-4 py-2">
|
|
<span className="flex items-center gap-1 text-[0.625rem] text-muted-foreground">
|
|
<kbd className="rounded border border-border bg-background px-1 py-px font-mono">↑↓</kbd>
|
|
Navigate
|
|
</span>
|
|
<span className="flex items-center gap-1 text-[0.625rem] text-muted-foreground">
|
|
<kbd className="rounded border border-border bg-background px-1 py-px font-mono">↵</kbd>
|
|
Open
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|