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:
chihlasm
2026-03-16 00:47:48 -04:00
parent 124f794535
commit c35c0230d9

View File

@@ -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 &ldquo;{query}&rdquo;
</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">
&ldquo;{item.subtitle}&rdquo;
</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>