'use client' import { useState, useRef, useEffect, useCallback } from 'react' import { createPortal } from 'react-dom' import { useTranslations } from 'next-intl' import { useMissionControl, type ConnectionStatus } from '@/store' import { extractWsHost } from '@/lib/agent-card-helpers' import { useWebSocket } from '@/lib/websocket' import { useNavigateToPanel, usePrefetchPanel } from '@/lib/navigation' import { Button } from '@/components/ui/button' import { ThemeSelector } from '@/components/ui/theme-selector' import { LanguageSwitcher } from '@/components/ui/language-switcher' import { DigitalClock } from '@/components/ui/digital-clock' import { getNavigationMetrics, navigationMetricEventName } from '@/lib/navigation-metrics' interface SearchResult { type: string id: number title: string subtitle?: string excerpt?: string created_at: number panel?: string source?: 'command' | 'entity' } const QUICK_NAV_COMMANDS: Array<{ panel: string; titleKey: string; title: string; aliases: string[] }> = [ { panel: 'overview', titleKey: 'goToOverview', title: 'Go to Overview', aliases: ['home', 'dashboard'] }, { panel: 'chat', titleKey: 'goToChat', title: 'Go to Chat', aliases: ['sessions', 'messages'] }, { panel: 'tasks', titleKey: 'goToTasks', title: 'Go to Tasks', aliases: ['task board', 'tickets'] }, { panel: 'agents', titleKey: 'goToAgents', title: 'Go to Agents', aliases: ['agent squad', 'workers'] }, { panel: 'activity', titleKey: 'goToActivityFeed', title: 'Go to Activity Feed', aliases: ['events', 'feed'] }, { panel: 'notifications', titleKey: 'goToNotifications', title: 'Go to Notifications', aliases: ['alerts inbox'] }, { panel: 'tokens', titleKey: 'goToTokenUsage', title: 'Go to Token Usage', aliases: ['cost', 'spend'] }, { panel: 'logs', titleKey: 'goToLogs', title: 'Go to Logs', aliases: ['log viewer'] }, { panel: 'memory', titleKey: 'goToMemoryBrowser', title: 'Go to Memory Browser', aliases: ['knowledge', 'notes'] }, { panel: 'integrations', titleKey: 'goToIntegrations', title: 'Go to Integrations', aliases: ['providers', 'api keys'] }, { panel: 'settings', titleKey: 'goToSettings', title: 'Go to Settings', aliases: ['preferences', 'config'] }, { panel: 'gateways', titleKey: 'goToGateways', title: 'Go to Gateways', aliases: ['gateway manager'] }, { panel: 'github', titleKey: 'goToGithubSync', title: 'Go to GitHub Sync', aliases: ['github', 'sync'] }, { panel: 'office', titleKey: 'goToOffice', title: 'Go to Office', aliases: ['workspace', 'team'] }, { panel: 'skills', titleKey: 'goToSkills', title: 'Go to Skills', aliases: ['skill packs', 'agent skills'] }, ] export function HeaderBar() { const { connection, sessions, unreadNotificationCount, activeTenant, activeProject, dashboardMode } = useMissionControl() const { isConnected, reconnect } = useWebSocket() const navigateToPanel = useNavigateToPanel() const prefetchPanel = usePrefetchPanel() const th = useTranslations('header') const activeSessions = sessions.filter(s => s.active).length // Search state const [searchOpen, setSearchOpen] = useState(false) const [searchQuery, setSearchQuery] = useState('') const [searchResults, setSearchResults] = useState([]) const [searchLoading, setSearchLoading] = useState(false) const [selectedIndex, setSelectedIndex] = useState(0) const searchRef = useRef(null) const searchInputRef = useRef(null) const resultButtonRefs = useRef>([]) const searchTimeoutRef = useRef>(undefined) const [isMounted, setIsMounted] = useState(false) useEffect(() => { setIsMounted(true) }, []) const getQuickNavResults = useCallback((q: string): SearchResult[] => { const normalized = q.trim().toLowerCase() if (!normalized) { return QUICK_NAV_COMMANDS.slice(0, 6).map((cmd, index) => ({ type: 'panel', id: -(index + 1), title: th(cmd.titleKey), subtitle: `/${cmd.panel}`, excerpt: th('quickNavigation'), created_at: Date.now(), panel: cmd.panel, source: 'command', })) } const ranked: Array<(SearchResult & { _score: number }) | null> = QUICK_NAV_COMMANDS .map((cmd, index) => { const translatedTitle = th(cmd.titleKey) const haystack = `${translatedTitle} ${cmd.title} ${cmd.panel} ${cmd.aliases.join(' ')}`.toLowerCase() if (!haystack.includes(normalized)) return null const exactPanel = cmd.panel === normalized const startsTitle = translatedTitle.toLowerCase().startsWith(normalized) const score = exactPanel ? 3 : startsTitle ? 2 : 1 return { type: 'panel', id: -(index + 1), title: translatedTitle, subtitle: `/${cmd.panel}`, excerpt: cmd.aliases.length ? `Aliases: ${cmd.aliases.join(', ')}` : th('quickNavigation'), created_at: Date.now(), panel: cmd.panel, source: 'command' as const, _score: score, } }) return ranked .filter((row): row is SearchResult & { _score: number } => Boolean(row)) .sort((a, b) => b._score - a._score) .map(({ _score, ...row }) => row) .slice(0, 8) }, [th]) const openCommandPalette = useCallback(() => { setSearchOpen(true) setSearchResults(getQuickNavResults('')) setSelectedIndex(0) setTimeout(() => searchInputRef.current?.focus(), 50) }, [getQuickNavResults]) const handleResultClick = useCallback((result: SearchResult) => { if (result.panel) { prefetchPanel(result.panel) navigateToPanel(result.panel) setSearchOpen(false) setSearchQuery('') setSearchResults([]) return } const typeToTab: Record = { task: 'tasks', agent: 'agents', activity: 'activity', audit: 'audit', message: 'agents', notification: 'notifications', webhook: 'webhooks', pipeline: 'agents', alert_rule: 'alerts', } navigateToPanel(typeToTab[result.type] || 'overview') setSearchOpen(false) setSearchQuery('') setSearchResults([]) }, [navigateToPanel, prefetchPanel]) // Keyboard shortcut: Cmd/Ctrl+K useEffect(() => { const handler = (e: KeyboardEvent) => { const target = e.target as HTMLElement | null const isTypingTarget = !!target && ( target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target.isContentEditable ) if (searchOpen) { if (e.key === 'Tab') { const focusables = [ searchInputRef.current, ...resultButtonRefs.current, ].filter((el): el is HTMLInputElement | HTMLButtonElement => el !== null) if (focusables.length > 0) { e.preventDefault() const activeEl = document.activeElement as (HTMLInputElement | HTMLButtonElement | null) const currentIndex = focusables.findIndex((el) => el === activeEl) const nextIndex = e.shiftKey ? (currentIndex <= 0 ? focusables.length - 1 : currentIndex - 1) : (currentIndex >= focusables.length - 1 ? 0 : currentIndex + 1) focusables[nextIndex]?.focus() } return } if (e.key === 'ArrowDown') { e.preventDefault() const next = Math.min(selectedIndex + 1, Math.max(0, searchResults.length - 1)) setSelectedIndex(next) resultButtonRefs.current[next]?.focus() return } if (e.key === 'ArrowUp') { e.preventDefault() const next = Math.max(selectedIndex - 1, 0) setSelectedIndex(next) resultButtonRefs.current[next]?.focus() return } if (e.key === 'Home') { e.preventDefault() setSelectedIndex(0) resultButtonRefs.current[0]?.focus() return } if (e.key === 'End') { e.preventDefault() const last = Math.max(0, searchResults.length - 1) setSelectedIndex(last) resultButtonRefs.current[last]?.focus() return } if (e.key === 'Enter') { const selected = searchResults[selectedIndex] if (selected) { e.preventDefault() handleResultClick(selected) return } } } if (!isTypingTarget && e.key === '/') { e.preventDefault() openCommandPalette() return } if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault() openCommandPalette() } if (e.key === 'Escape') setSearchOpen(false) } window.addEventListener('keydown', handler) return () => window.removeEventListener('keydown', handler) }, [handleResultClick, openCommandPalette, searchOpen, searchResults, selectedIndex]) // Close on outside click useEffect(() => { if (!searchOpen) return const handler = (e: MouseEvent) => { if (searchRef.current && !searchRef.current.contains(e.target as Node)) { setSearchOpen(false) } } document.addEventListener('mousedown', handler) return () => document.removeEventListener('mousedown', handler) }, [searchOpen]) // Prevent background scroll while command palette is open. useEffect(() => { if (!searchOpen) return const prev = document.body.style.overflow document.body.style.overflow = 'hidden' return () => { document.body.style.overflow = prev } }, [searchOpen]) useEffect(() => { if (!searchOpen) return resultButtonRefs.current[selectedIndex]?.scrollIntoView({ block: 'nearest' }) }, [searchOpen, selectedIndex, searchResults]) const doSearch = useCallback(async (q: string) => { const quickResults = getQuickNavResults(q) if (q.length < 2) { setSearchResults(quickResults) setSelectedIndex(0) return } setSearchLoading(true) try { const res = await fetch(`/api/search?q=${encodeURIComponent(q)}&limit=12`) const data = await res.json() const entityResults: SearchResult[] = (data.results || []).map((r: SearchResult) => ({ ...r, source: 'entity' })) const merged = [...quickResults, ...entityResults].slice(0, 16) setSearchResults(merged) setSelectedIndex(0) } catch { setSearchResults(quickResults) setSelectedIndex(0) } finally { setSearchLoading(false) } }, [getQuickNavResults]) const handleSearchInput = (value: string) => { setSearchQuery(value) setSelectedIndex(0) if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current) searchTimeoutRef.current = setTimeout(() => doSearch(value), 250) } useEffect(() => { resultButtonRefs.current = resultButtonRefs.current.slice(0, searchResults.length) }, [searchResults.length]) const typeIcons: Record = { panel: '>', task: 'T', agent: 'A', activity: 'E', audit: 'S', message: 'M', notification: 'N', webhook: 'W', pipeline: 'P', } const typeColors: Record = { panel: 'bg-primary/20 text-primary', task: 'bg-blue-500/20 text-blue-400', agent: 'bg-purple-500/20 text-purple-400', activity: 'bg-green-500/20 text-green-400', audit: 'bg-amber-500/20 text-amber-400', message: 'bg-cyan-500/20 text-cyan-400', notification: 'bg-red-500/20 text-red-400', webhook: 'bg-orange-500/20 text-orange-400', pipeline: 'bg-indigo-500/20 text-indigo-400', } return (
{/* Left: Page title + context */}
{activeProject ? ( ) : activeTenant ? (
{th('workspace')} / {activeTenant.display_name}
) : null}
{/* Center: wide command search (desktop) */}
{/* Right: status + actions */}
{/* Mobile search trigger */}
{/* Search overlay (portal to body to avoid clipping/stacking context bugs) */} {searchOpen && isMounted && createPortal(
setSearchOpen(false)} />
handleSearchInput(e.target.value)} placeholder={th('searchPlaceholder')} className="w-full h-9 px-3 rounded-md bg-secondary border-0 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none" autoFocus role="combobox" aria-expanded={searchOpen} aria-controls="mc-command-results" aria-activedescendant={searchResults[selectedIndex] ? `mc-command-result-${selectedIndex}` : undefined} />
{searchLoading ? (
{th('searching')}
) : searchResults.length > 0 ? ( searchResults.map((r, i) => ( )) ) : searchQuery.length >= 2 ? (
{th('noResults')}
) : (
{th('typeToSearch')}
)}
, document.body )}
) } /** Top-left mode + connection badge — visible on all screen sizes. */ function ModeBadge({ connection, onReconnect, }: { connection: ConnectionStatus onReconnect: () => void }) { const { dashboardMode } = useMissionControl() const th = useTranslations('header') const isLocal = dashboardMode === 'local' const [showTooltip, setShowTooltip] = useState(false) if (isLocal) { return (
{th('local')}
) } const isConnected = connection.isConnected const isReconnecting = !isConnected && connection.reconnectAttempts > 0 let dotClass: string let borderClass: string let textClass: string let statusLabel: string if (isConnected) { dotClass = 'bg-green-500' borderClass = 'border-green-500/25 bg-green-500/10' textClass = 'text-green-400' statusLabel = connection.latency != null ? `${connection.latency}ms` : th('connected') } else if (isReconnecting) { dotClass = 'bg-amber-500 animate-pulse' borderClass = 'border-amber-500/25 bg-amber-500/10' textClass = 'text-amber-400' statusLabel = th('retry', { count: connection.reconnectAttempts }) } else { dotClass = 'bg-red-500 animate-pulse' borderClass = 'border-red-500/25 bg-red-500/10' textClass = 'text-red-400' statusLabel = th('offline') } const wsHost = extractWsHost(connection.url) return (
setShowTooltip(true)} onMouseLeave={() => setShowTooltip(false)} > {showTooltip && (
{th('gatewayConnection')}
{th('status')} {isConnected ? th('connected') : isReconnecting ? th('reconnecting') : th('disconnected')}
{th('host')} {wsHost}
{connection.latency != null && (
{th('latency')} {connection.latency}ms
)}
{th('webSocket')} {isConnected ? th('live') : th('down')}
{th('sse')} {connection.sseConnected ? th('live') : th('off')}
{!isConnected && connection.reconnectAttempts > 0 && (
{th('retries')} {connection.reconnectAttempts}
)}
{!isConnected && (
{th('clickToReconnect')}
)}
)}
) } function Stat({ label, value, status }: { label: string; value: string; status?: 'success' | 'error' | 'warning' }) { const statusColor = status === 'success' ? 'text-green-400' : status === 'error' ? 'text-red-400' : status === 'warning' ? 'text-amber-400' : 'text-foreground' return (
{label} {value}
) } function NavigationLatencyStat() { const [latestMs, setLatestMs] = useState(null) const [avgMs, setAvgMs] = useState(null) useEffect(() => { const initial = getNavigationMetrics() setLatestMs(initial.latestMs) setAvgMs(initial.avgMs) const eventName = navigationMetricEventName() const update = () => { const metrics = getNavigationMetrics() setLatestMs(metrics.latestMs) setAvgMs(metrics.avgMs) } window.addEventListener(eventName, update as EventListener) return () => window.removeEventListener(eventName, update as EventListener) }, []) if (latestMs == null) return null const latest = `${Math.round(latestMs)}ms` const avg = avgMs == null ? '' : ` (${Math.round(avgMs)} avg)` return } function SseBadge({ connected }: { connected: boolean }) { const th = useTranslations('header') return (
{th('events')} {connected ? th('live') : th('off')}
) } function SearchIcon() { return ( ) } function BellIcon() { return ( ) }