search-dialog.tsx
1 'use client' 2 3 import { useState, useEffect, useRef, useCallback } from 'react' 4 import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog' 5 import { useAppStore } from '@/stores/use-app-store' 6 import { api } from '@/lib/app/api-client' 7 import { isLocalhostBrowser, isVisibleSessionForViewer } from '@/lib/observability/local-observability' 8 import { useNavigate } from '@/lib/app/navigation' 9 import { InfoChip } from '@/components/ui/info-chip' 10 import type { AppView } from '@/types' 11 12 interface SearchResult { 13 type: 'task' | 'agent' | 'session' | 'schedule' | 'webhook' | 'skill' | 'message' 14 id: string 15 title: string 16 description?: string 17 status?: string 18 messageIndex?: number 19 } 20 21 const TYPE_ICONS: Record<SearchResult['type'], string> = { 22 agent: 'M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2', 23 task: 'M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2', 24 session: 'M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z', 25 schedule: 'M12 6v6l4 2', 26 webhook: 'M22 12h-4l-3 7L9 5l-3 7H2', 27 skill: 'M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z', 28 message: 'M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z', 29 } 30 31 const TYPE_EXTRA_PATHS: Partial<Record<SearchResult['type'], string>> = { 32 agent: 'M12 7a4 4 0 1 0 0-0.01', 33 schedule: 'M12 12a10 10 0 1 0 0-0.01', 34 message: 'M8 10h8', 35 } 36 37 const TYPE_VIEW_MAP: Record<SearchResult['type'], AppView> = { 38 agent: 'agents', 39 task: 'tasks', 40 session: 'agents', 41 schedule: 'schedules', 42 webhook: 'webhooks', 43 skill: 'skills', 44 message: 'agents', 45 } 46 47 const TYPE_LABELS: Record<SearchResult['type'], string> = { 48 agent: 'Agent', 49 task: 'Task', 50 session: 'Chat', 51 schedule: 'Schedule', 52 webhook: 'Webhook', 53 skill: 'Skill', 54 message: 'Message', 55 } 56 57 export function SearchDialog() { 58 const [open, setOpen] = useState(false) 59 const [query, setQuery] = useState('') 60 const [results, setResults] = useState<SearchResult[]>([]) 61 const [selectedIdx, setSelectedIdx] = useState(0) 62 const [loading, setLoading] = useState(false) 63 const inputRef = useRef<HTMLInputElement>(null) 64 const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null) 65 const listRef = useRef<HTMLDivElement>(null) 66 67 const sessions = useAppStore((s) => s.sessions) 68 const currentUser = useAppStore((s) => s.currentUser) 69 const navigateToView = useNavigate() 70 const setSidebarOpen = useAppStore((s) => s.setSidebarOpen) 71 const setEditingAgentId = useAppStore((s) => s.setEditingAgentId) 72 const setAgentSheetOpen = useAppStore((s) => s.setAgentSheetOpen) 73 const setEditingTaskId = useAppStore((s) => s.setEditingTaskId) 74 const setTaskSheetOpen = useAppStore((s) => s.setTaskSheetOpen) 75 const setEditingScheduleId = useAppStore((s) => s.setEditingScheduleId) 76 const setScheduleSheetOpen = useAppStore((s) => s.setScheduleSheetOpen) 77 const setEditingWebhookId = useAppStore((s) => s.setEditingWebhookId) 78 const setWebhookSheetOpen = useAppStore((s) => s.setWebhookSheetOpen) 79 const setEditingSkillId = useAppStore((s) => s.setEditingSkillId) 80 const setSkillSheetOpen = useAppStore((s) => s.setSkillSheetOpen) 81 const setCurrentAgent = useAppStore((s) => s.setCurrentAgent) 82 83 // Global Cmd+K / Ctrl+K listener 84 useEffect(() => { 85 const handler = (e: KeyboardEvent) => { 86 if ((e.metaKey || e.ctrlKey) && e.key === 'k') { 87 e.preventDefault() 88 setOpen((v) => !v) 89 } 90 } 91 window.addEventListener('keydown', handler) 92 return () => window.removeEventListener('keydown', handler) 93 }, []) 94 95 // Listen for custom event from sidebar button 96 useEffect(() => { 97 const handler = () => setOpen(true) 98 window.addEventListener('swarmclaw:open-search', handler) 99 return () => window.removeEventListener('swarmclaw:open-search', handler) 100 }, []) 101 102 // Reset on open 103 useEffect(() => { 104 if (!open) return 105 setQuery('') 106 setResults([]) 107 setSelectedIdx(0) 108 const timer = setTimeout(() => inputRef.current?.focus(), 50) 109 return () => clearTimeout(timer) 110 }, [open]) 111 112 // Debounced search 113 const doSearch = useCallback(async (q: string) => { 114 if (q.trim().length < 2) { 115 setResults([]) 116 setLoading(false) 117 return 118 } 119 setLoading(true) 120 try { 121 const data = await api<{ results: SearchResult[] }>('GET', `/search?q=${encodeURIComponent(q)}`) 122 setResults(data.results.filter((result) => { 123 if (result.type !== 'session' && result.type !== 'message') return true 124 const session = sessions[result.id] 125 if (!session) return true 126 return isVisibleSessionForViewer(session, currentUser, { localhost: isLocalhostBrowser() }) 127 })) 128 setSelectedIdx(0) 129 } catch { 130 setResults([]) 131 } finally { 132 setLoading(false) 133 } 134 }, [currentUser, sessions]) 135 136 const handleQueryChange = (value: string) => { 137 setQuery(value) 138 if (debounceRef.current) clearTimeout(debounceRef.current) 139 debounceRef.current = setTimeout(() => doSearch(value), 300) 140 } 141 142 // Navigate to a result 143 const goToResult = useCallback((result: SearchResult) => { 144 setOpen(false) 145 const view = TYPE_VIEW_MAP[result.type] 146 navigateToView(view) 147 setSidebarOpen(true) 148 149 switch (result.type) { 150 case 'agent': 151 setEditingAgentId(result.id) 152 setAgentSheetOpen(true) 153 break 154 case 'task': 155 setEditingTaskId(result.id) 156 setTaskSheetOpen(true) 157 break 158 case 'session': { 159 const sessionAgentId = sessions[result.id]?.agentId 160 if (sessionAgentId) void setCurrentAgent(sessionAgentId) 161 navigateToView('agents') 162 break 163 } 164 case 'message': { 165 const msgSessionAgentId = sessions[result.id]?.agentId 166 if (msgSessionAgentId) void setCurrentAgent(msgSessionAgentId) 167 navigateToView('agents') 168 // Scroll to the matched message after the chat renders 169 if (result.messageIndex != null) { 170 setTimeout(() => { 171 window.dispatchEvent(new CustomEvent('swarmclaw:scroll-to-message', { detail: { index: result.messageIndex } })) 172 }, 300) 173 } 174 break 175 } 176 case 'schedule': 177 setEditingScheduleId(result.id) 178 setScheduleSheetOpen(true) 179 break 180 case 'webhook': 181 setEditingWebhookId(result.id) 182 setWebhookSheetOpen(true) 183 break 184 case 'skill': 185 setEditingSkillId(result.id) 186 setSkillSheetOpen(true) 187 break 188 } 189 // eslint-disable-next-line react-hooks/exhaustive-deps 190 }, []) 191 192 // Keyboard navigation 193 const handleKeyDown = (e: React.KeyboardEvent) => { 194 if (e.key === 'ArrowDown') { 195 e.preventDefault() 196 setSelectedIdx((i) => Math.min(i + 1, results.length - 1)) 197 } else if (e.key === 'ArrowUp') { 198 e.preventDefault() 199 setSelectedIdx((i) => Math.max(i - 1, 0)) 200 } else if (e.key === 'Enter' && results[selectedIdx]) { 201 e.preventDefault() 202 goToResult(results[selectedIdx]) 203 } 204 } 205 206 // Scroll selected into view 207 useEffect(() => { 208 if (!listRef.current) return 209 const el = listRef.current.children[selectedIdx] as HTMLElement | undefined 210 el?.scrollIntoView({ block: 'nearest' }) 211 }, [selectedIdx]) 212 213 return ( 214 <Dialog open={open} onOpenChange={setOpen}> 215 <DialogContent 216 showCloseButton={false} 217 className="sm:max-w-[520px] p-0 bg-surface/95 backdrop-blur-xl border-white/[0.08] shadow-[0_24px_80px_rgba(0,0,0,0.6)] rounded-[16px] overflow-hidden gap-0" 218 onKeyDown={handleKeyDown} 219 > 220 <DialogTitle className="sr-only">Search</DialogTitle> 221 {/* Search input */} 222 <div className="flex items-center gap-3 px-4 py-3 border-b border-white/[0.06]"> 223 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="text-text-3 shrink-0"> 224 <circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" /> 225 </svg> 226 <input 227 ref={inputRef} 228 value={query} 229 onChange={(e) => handleQueryChange(e.target.value)} 230 placeholder="Search agents, tasks, schedules..." 231 aria-label="Search" 232 className="flex-1 bg-transparent border-none outline-none text-[14px] text-text placeholder:text-text-3/60 font-[inherit]" 233 autoFocus 234 /> 235 <kbd className="px-1.5 py-0.5 rounded-[5px] bg-white/[0.06] border border-white/[0.08] text-[10px] font-mono text-text-3 shrink-0"> 236 ESC 237 </kbd> 238 </div> 239 240 {/* Results */} 241 <div ref={listRef} className="max-h-[360px] overflow-y-auto py-1"> 242 {loading && query.length >= 2 && ( 243 <div className="px-4 py-8 text-center text-[13px] text-text-3"> 244 Searching... 245 </div> 246 )} 247 {!loading && query.length >= 2 && results.length === 0 && ( 248 <div className="px-4 py-8 text-center text-[13px] text-text-3"> 249 No results found 250 </div> 251 )} 252 {!loading && query.length < 2 && ( 253 <div className="px-4 py-8 text-center text-[13px] text-text-3/60"> 254 Type at least 2 characters to search 255 </div> 256 )} 257 {results.map((result, idx) => ( 258 <button 259 key={result.type === 'message' ? `${result.type}-${result.id}-${result.messageIndex}` : `${result.type}-${result.id}`} 260 onClick={() => goToResult(result)} 261 onMouseEnter={() => setSelectedIdx(idx)} 262 className={`w-full flex items-center gap-3 px-4 py-2.5 text-left cursor-pointer transition-colors border-none bg-transparent focus-visible:ring-1 focus-visible:ring-accent-bright/50 focus-visible:ring-inset 263 ${idx === selectedIdx ? 'bg-white/[0.06]' : 'hover:bg-white/[0.04]'}`} 264 style={{ fontFamily: 'inherit' }} 265 > 266 {/* Type icon */} 267 <div className={`w-8 h-8 rounded-[8px] flex items-center justify-center shrink-0 268 ${idx === selectedIdx ? 'bg-accent-bright/20' : 'bg-white/[0.04]'}`}> 269 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" 270 className={idx === selectedIdx ? 'text-accent-bright' : 'text-text-3'}> 271 <path d={TYPE_ICONS[result.type]} /> 272 {TYPE_EXTRA_PATHS[result.type] && <path d={TYPE_EXTRA_PATHS[result.type]} />} 273 </svg> 274 </div> 275 {/* Content */} 276 <div className="flex-1 min-w-0"> 277 <div className="flex items-center gap-2"> 278 <span className="text-[13px] font-500 text-text truncate">{result.title}</span> 279 {result.status && ( 280 <InfoChip size="sm" tone="muted" className="font-500"> 281 {result.status} 282 </InfoChip> 283 )} 284 </div> 285 {result.description && ( 286 <p className="text-[11px] text-text-3 truncate mt-0.5 m-0">{result.description}</p> 287 )} 288 </div> 289 {/* Type label */} 290 <span className="text-[10px] text-text-3/60 uppercase tracking-wider shrink-0"> 291 {TYPE_LABELS[result.type]} 292 </span> 293 </button> 294 ))} 295 </div> 296 297 {/* Footer hint */} 298 {results.length > 0 && ( 299 <div className="flex items-center gap-3 px-4 py-2 border-t border-white/[0.06] text-[11px] text-text-3/50"> 300 <span className="flex items-center gap-1"> 301 <kbd className="px-1 py-0.5 rounded bg-white/[0.06] text-[10px] font-mono">↑↓</kbd> 302 navigate 303 </span> 304 <span className="flex items-center gap-1"> 305 <kbd className="px-1 py-0.5 rounded bg-white/[0.06] text-[10px] font-mono">↵</kbd> 306 open 307 </span> 308 <span className="flex items-center gap-1"> 309 <kbd className="px-1 py-0.5 rounded bg-white/[0.06] text-[10px] font-mono">esc</kbd> 310 close 311 </span> 312 </div> 313 )} 314 </DialogContent> 315 </Dialog> 316 ) 317 }