/ src / components / shared / search-dialog.tsx
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  }