Spaces:
Sleeping
Sleeping
nyk
feat: full i18n — 1752 keys across 10 languages, all panels translated (#326)
b180108 unverified | '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<SearchResult[]>([]) | |
| const [searchLoading, setSearchLoading] = useState(false) | |
| const [selectedIndex, setSelectedIndex] = useState(0) | |
| const searchRef = useRef<HTMLDivElement>(null) | |
| const searchInputRef = useRef<HTMLInputElement>(null) | |
| const resultButtonRefs = useRef<Array<HTMLButtonElement | null>>([]) | |
| const searchTimeoutRef = useRef<ReturnType<typeof setTimeout>>(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<string, string> = { | |
| 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<string, string> = { | |
| panel: '>', | |
| task: 'T', agent: 'A', activity: 'E', audit: 'S', | |
| message: 'M', notification: 'N', webhook: 'W', pipeline: 'P', | |
| } | |
| const typeColors: Record<string, string> = { | |
| 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 ( | |
| <header role="banner" aria-label="Application header" className="relative z-50 h-14 bg-card/80 backdrop-blur-sm border-b border-border px-3 md:px-4 shrink-0"> | |
| <div className="h-full flex items-center gap-2 md:gap-3"> | |
| {/* Left: Page title + context */} | |
| <div className="flex min-w-0 items-center gap-2.5 shrink-0"> | |
| {activeProject ? ( | |
| <Button | |
| variant="outline" | |
| size="xs" | |
| onClick={() => navigateToPanel('tasks')} | |
| onMouseEnter={() => prefetchPanel('tasks')} | |
| onFocus={() => prefetchPanel('tasks')} | |
| className="hidden lg:flex items-center gap-1 text-2xs bg-secondary/50 min-w-0 max-w-[320px]" | |
| title={`Scoped to project: ${activeProject.name}`} | |
| > | |
| <span className="text-muted-foreground/60 truncate">{activeTenant?.display_name || 'Default'}</span> | |
| <span className="text-muted-foreground/40">/</span> | |
| <span className="font-medium text-foreground truncate">{activeProject.name}</span> | |
| </Button> | |
| ) : activeTenant ? ( | |
| <div className="hidden lg:flex items-center gap-1 px-2 py-1 rounded-md bg-secondary/40 text-2xs"> | |
| <span className="text-muted-foreground">{th('workspace')}</span> | |
| <span className="text-muted-foreground/40">/</span> | |
| <span className="font-medium text-foreground truncate max-w-[220px]">{activeTenant.display_name}</span> | |
| </div> | |
| ) : null} | |
| <ModeBadge connection={connection} onReconnect={reconnect} /> | |
| </div> | |
| {/* Center: wide command search (desktop) */} | |
| <div className="hidden md:flex items-center justify-center flex-1 min-w-0 max-w-[28rem] lg:max-w-[34rem] xl:max-w-[42rem]"> | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| onClick={openCommandPalette} | |
| className="h-10 w-full justify-between bg-secondary/35 hover:border-primary/40 hover:bg-secondary/50 px-3" | |
| > | |
| <span className="flex items-center gap-2 min-w-0"> | |
| <SearchIcon /> | |
| <span className="truncate text-sm text-muted-foreground">{th('jumpToSearch')}</span> | |
| </span> | |
| <span className="hidden xl:flex items-center gap-1 ml-2 shrink-0"> | |
| <kbd className="text-2xs px-1.5 py-0.5 rounded bg-muted border border-border font-mono">⌘K</kbd> | |
| <kbd className="text-2xs px-1.5 py-0.5 rounded bg-muted border border-border font-mono">/</kbd> | |
| </span> | |
| </Button> | |
| </div> | |
| {/* Right: status + actions */} | |
| <div className="flex items-center justify-end gap-1.5 md:gap-2 min-w-0 shrink-0 ml-auto"> | |
| <div className="hidden xl:flex items-center gap-3"> | |
| <Stat label={th('sessions')} value={`${activeSessions}/${sessions.length}`} /> | |
| <NavigationLatencyStat /> | |
| <SseBadge connected={connection.sseConnected ?? false} /> | |
| <DigitalClock /> | |
| </div> | |
| {/* Mobile search trigger */} | |
| <Button | |
| variant="ghost" | |
| size="icon-sm" | |
| onClick={openCommandPalette} | |
| className="md:hidden" | |
| title="Search" | |
| > | |
| <SearchIcon /> | |
| </Button> | |
| <Button | |
| variant="ghost" | |
| size="icon-sm" | |
| onClick={() => navigateToPanel('notifications')} | |
| onMouseEnter={() => prefetchPanel('notifications')} | |
| onFocus={() => prefetchPanel('notifications')} | |
| className="relative" | |
| > | |
| <BellIcon /> | |
| {unreadNotificationCount > 0 && ( | |
| <span className="absolute -top-0.5 -right-0.5 w-4 h-4 rounded-full bg-primary text-primary-foreground text-2xs flex items-center justify-center font-medium"> | |
| {unreadNotificationCount > 9 ? '9+' : unreadNotificationCount} | |
| </span> | |
| )} | |
| </Button> | |
| <LanguageSwitcher /> | |
| <ThemeSelector /> | |
| </div> | |
| </div> | |
| {/* Search overlay (portal to body to avoid clipping/stacking context bugs) */} | |
| {searchOpen && isMounted && createPortal( | |
| <div | |
| ref={searchRef} | |
| className="fixed inset-0 z-[9999] isolate" | |
| role="dialog" | |
| aria-modal="true" | |
| aria-label="Command search" | |
| > | |
| <div className="absolute inset-0 bg-gradient-to-b from-black/30 via-black/30 to-black/30" onClick={() => setSearchOpen(false)} /> | |
| <div className="absolute inset-0 flex items-center justify-center p-4"> | |
| <div className="command-palette-in w-full max-w-[44rem] max-h-[min(78vh,40rem)] bg-card border border-border rounded-lg shadow-2xl overflow-hidden"> | |
| <div className="p-2 border-b border-border"> | |
| <input | |
| ref={searchInputRef} | |
| type="text" | |
| value={searchQuery} | |
| onChange={e => 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} | |
| /> | |
| </div> | |
| <div id="mc-command-results" role="listbox" className="bg-card max-h-[calc(min(78vh,40rem)-3.25rem)] overflow-y-auto"> | |
| {searchLoading ? ( | |
| <div className="p-4 text-center text-xs text-muted-foreground">{th('searching')}</div> | |
| ) : searchResults.length > 0 ? ( | |
| searchResults.map((r, i) => ( | |
| <Button | |
| key={`${r.type}-${r.id}-${i}`} | |
| ref={(el) => { resultButtonRefs.current[i] = el }} | |
| variant="ghost" | |
| onClick={() => handleResultClick(r)} | |
| onMouseEnter={() => setSelectedIndex(i)} | |
| id={`mc-command-result-${i}`} | |
| role="option" | |
| aria-selected={i === selectedIndex} | |
| tabIndex={i === selectedIndex ? 0 : -1} | |
| className={`w-full text-left px-3 py-2 h-auto rounded-none justify-start items-start gap-2.5 hover:bg-secondary/80 ${ | |
| i === selectedIndex ? 'bg-secondary' : 'bg-card' | |
| }`} | |
| > | |
| <span className={`text-2xs font-medium w-5 h-5 rounded flex items-center justify-center shrink-0 mt-0.5 ${typeColors[r.type] || 'bg-muted text-muted-foreground'}`}> | |
| {typeIcons[r.type] || '?'} | |
| </span> | |
| <div className="flex-1 min-w-0"> | |
| <div className="text-xs font-medium text-foreground truncate">{r.title}</div> | |
| {r.subtitle && <div className="text-2xs text-muted-foreground truncate">{r.subtitle}</div>} | |
| {r.excerpt && <div className="text-2xs text-muted-foreground/70 truncate mt-0.5">{r.excerpt}</div>} | |
| </div> | |
| </Button> | |
| )) | |
| ) : searchQuery.length >= 2 ? ( | |
| <div className="p-4 text-center text-xs text-muted-foreground">{th('noResults')}</div> | |
| ) : ( | |
| <div className="p-4 text-center text-xs text-muted-foreground">{th('typeToSearch')}</div> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| </div>, | |
| document.body | |
| )} | |
| </header> | |
| ) | |
| } | |
| /** 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 ( | |
| <div className="flex items-center gap-1.5 px-2 py-1 rounded-md text-2xs bg-void-cyan/10 border border-void-cyan/25"> | |
| <span className="w-1.5 h-1.5 rounded-full bg-void-cyan" /> | |
| <span className="font-medium text-void-cyan">{th('local')}</span> | |
| </div> | |
| ) | |
| } | |
| 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 ( | |
| <div | |
| className="relative" | |
| onMouseEnter={() => setShowTooltip(true)} | |
| onMouseLeave={() => setShowTooltip(false)} | |
| > | |
| <button | |
| onClick={!isConnected ? onReconnect : undefined} | |
| className={`flex items-center gap-1.5 px-2 py-1 rounded-md text-2xs border ${borderClass} ${ | |
| !isConnected ? 'cursor-pointer hover:brightness-125' : 'cursor-default' | |
| } transition-all`} | |
| > | |
| <span className={`w-1.5 h-1.5 rounded-full ${dotClass}`} /> | |
| <span className={`font-medium ${textClass}`}>GW</span> | |
| <span className={`font-mono ${textClass} opacity-80`}>{statusLabel}</span> | |
| </button> | |
| {showTooltip && ( | |
| <div className="absolute top-full left-0 mt-1.5 z-50 w-56 rounded-lg border border-border bg-card/95 backdrop-blur-md p-3 shadow-xl text-xs"> | |
| <div className="font-medium text-foreground mb-2">{th('gatewayConnection')}</div> | |
| <div className="space-y-1.5 text-muted-foreground"> | |
| <div className="flex justify-between"> | |
| <span>{th('status')}</span> | |
| <span className={isConnected ? 'text-green-400' : isReconnecting ? 'text-amber-400' : 'text-red-400'}> | |
| {isConnected ? th('connected') : isReconnecting ? th('reconnecting') : th('disconnected')} | |
| </span> | |
| </div> | |
| <div className="flex justify-between"> | |
| <span>{th('host')}</span> | |
| <span className="font-mono text-foreground/80 truncate ml-2">{wsHost}</span> | |
| </div> | |
| {connection.latency != null && ( | |
| <div className="flex justify-between"> | |
| <span>{th('latency')}</span> | |
| <span className="font-mono text-foreground/80">{connection.latency}ms</span> | |
| </div> | |
| )} | |
| <div className="flex justify-between"> | |
| <span>{th('webSocket')}</span> | |
| <span className={isConnected ? 'text-green-400' : 'text-red-400'}> | |
| {isConnected ? th('live') : th('down')} | |
| </span> | |
| </div> | |
| <div className="flex justify-between"> | |
| <span>{th('sse')}</span> | |
| <span className={connection.sseConnected ? 'text-green-400' : 'text-muted-foreground/50'}> | |
| {connection.sseConnected ? th('live') : th('off')} | |
| </span> | |
| </div> | |
| {!isConnected && connection.reconnectAttempts > 0 && ( | |
| <div className="flex justify-between"> | |
| <span>{th('retries')}</span> | |
| <span className="text-amber-400">{connection.reconnectAttempts}</span> | |
| </div> | |
| )} | |
| </div> | |
| {!isConnected && ( | |
| <div className="mt-2 pt-2 border-t border-border/40 text-muted-foreground/60 text-[10px]"> | |
| {th('clickToReconnect')} | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| ) | |
| } | |
| 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 ( | |
| <div className="flex items-center gap-1.5 text-xs"> | |
| <span className="text-muted-foreground">{label}</span> | |
| <span className={`font-medium font-mono-tight ${statusColor}`}>{value}</span> | |
| </div> | |
| ) | |
| } | |
| function NavigationLatencyStat() { | |
| const [latestMs, setLatestMs] = useState<number | null>(null) | |
| const [avgMs, setAvgMs] = useState<number | null>(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 <Stat label="Nav" value={`${latest}${avg}`} /> | |
| } | |
| function SseBadge({ connected }: { connected: boolean }) { | |
| const th = useTranslations('header') | |
| return ( | |
| <div className="flex items-center gap-1.5 text-xs"> | |
| <span className="text-muted-foreground">{th('events')}</span> | |
| <span className={`w-1.5 h-1.5 rounded-full ${connected ? 'bg-blue-500' : 'bg-muted-foreground/30'}`} /> | |
| <span className={`font-medium font-mono-tight ${connected ? 'text-blue-400' : 'text-muted-foreground'}`}> | |
| {connected ? th('live') : th('off')} | |
| </span> | |
| </div> | |
| ) | |
| } | |
| function SearchIcon() { | |
| return ( | |
| <svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"> | |
| <circle cx="7" cy="7" r="4.5" /> | |
| <path d="M10.5 10.5L14 14" /> | |
| </svg> | |
| ) | |
| } | |
| function BellIcon() { | |
| return ( | |
| <svg className="w-4 h-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"> | |
| <path d="M6 13h4M3.5 10c0-1-1-2-1-4a5.5 5.5 0 0111 0c0 2-1 3-1 4H3.5z" /> | |
| </svg> | |
| ) | |
| } | |