feat: rewrite CommandPalette with categorized results and smart ranking
- Adds FlowPilot AI result (always present when query is non-empty) - Intent-aware ordering: question → FlowPilot prominent; page → pages first; keyword → FlowPilot at top with flows/sessions/tags below - Pages section with admin-gated items (uses useAuthStore) - Tags extracted from flow search results with ?tag= navigation - Quick Actions for create/import/scripts - Empty state shows recent flows + quick actions - Grouped rendering with section labels per design system - Keyboard nav flattened across groups Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 <Sparkles size={16} className={cls} />
|
||||
case 'tree': return <FileText size={16} className={cls} />
|
||||
case 'session': return <Clock 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 user = useAuthStore(s => s.user)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState<ResultItem[]>([])
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const [searchFlows, setSearchFlows] = useState<TreeListItem[]>([])
|
||||
const [searchSessions, setSearchSessions] = useState<Session[]>([])
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | 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<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,
|
||||
}))
|
||||
|
||||
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 (
|
||||
<div className="fixed inset-0 z-100 flex items-start justify-center pt-[20vh]">
|
||||
<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"
|
||||
@@ -142,7 +299,7 @@ export function CommandPalette({ open, onClose }: CommandPaletteProps) {
|
||||
value={query}
|
||||
onChange={e => { 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"
|
||||
/>
|
||||
<kbd className="rounded border border-border bg-background px-1.5 py-0.5 font-label text-[0.625rem] text-muted-foreground">
|
||||
@@ -151,55 +308,120 @@ export function CommandPalette({ open, onClose }: CommandPaletteProps) {
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div className="max-h-72 overflow-y-auto">
|
||||
<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>
|
||||
) : query.length >= 2 && results.length === 0 ? (
|
||||
) : hasQuery && flatItems.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
No results for “{query}”
|
||||
</div>
|
||||
) : results.length > 0 ? (
|
||||
) : builtGroups.length > 0 ? (
|
||||
<div className="p-1">
|
||||
{results.map((item, i) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => handleSelect(item)}
|
||||
onMouseEnter={() => setSelectedIndex(i)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left transition-colors',
|
||||
i === selectedIndex
|
||||
? 'bg-accent text-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent/50'
|
||||
)}
|
||||
>
|
||||
{item.type === 'tree' ? (
|
||||
<FileText size={16} className="shrink-0 opacity-60" />
|
||||
) : (
|
||||
<Clock size={16} className="shrink-0 opacity-60" />
|
||||
)}
|
||||
<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>
|
||||
)}
|
||||
{builtGroups.map(group => {
|
||||
const groupStart = globalIdx
|
||||
globalIdx += group.items.length
|
||||
|
||||
return (
|
||||
<div key={group.type}>
|
||||
{/* Section label */}
|
||||
<div className="px-3 pt-2 pb-1">
|
||||
<span className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
|
||||
{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}
|
||||
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/10 border-primary/20'
|
||||
: '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 transition-colors',
|
||||
isSelected
|
||||
? 'bg-accent text-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent/50'
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
{i === selectedIndex && (
|
||||
<ArrowRight size={14} className="shrink-0 opacity-40" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-4 py-6 text-center text-sm text-muted-foreground">
|
||||
Type to search flows and sessions
|
||||
{isEmpty
|
||||
? 'Type to search flows, pages, or ask FlowPilot a question'
|
||||
: 'Type to search flows and sessions'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer hints */}
|
||||
{results.length > 0 && (
|
||||
{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-label">↑↓</kbd>
|
||||
|
||||
Reference in New Issue
Block a user