feat: connection lost/reconnected banner with i18n

Adds a slim animated banner below the header that:
- Shows 'Connection lost — reconnecting…' with spinner when WS drops
- Flashes 'Reconnected!' with auto-dismiss after 3s on recovery
- Only appears after initial connection (not on first load)
- Supports EN/FR via i18n system
- Uses aria role=alert for accessibility
This commit is contained in:
Nicolas Varrot
2026-02-11 18:36:51 +00:00
parent 569dbc6d4d
commit 32a2166fd3
3 changed files with 81 additions and 0 deletions

View File

@@ -4,6 +4,7 @@ import { Header } from './components/Header';
import { Sidebar } from './components/Sidebar';
import { Chat } from './components/Chat';
import { LoginScreen } from './components/LoginScreen';
import { ConnectionBanner } from './components/ConnectionBanner';
export default function App() {
const {
@@ -38,6 +39,7 @@ export default function App() {
/>
<div className="flex-1 flex flex-col min-w-0">
<Header status={status} sessionKey={activeSession} onToggleSidebar={() => setSidebarOpen(!sidebarOpen)} activeSessionData={sessions.find(s => s.key === activeSession)} onLogout={logout} />
<ConnectionBanner status={status} />
<Chat messages={messages} isGenerating={isGenerating} status={status} onSend={sendMessage} onAbort={abort} />
</div>
</div>

View File

@@ -0,0 +1,72 @@
import { useState, useEffect, useRef } from 'react';
import { Wifi, Loader2 } from 'lucide-react';
import type { ConnectionStatus } from '../types';
import { useT } from '../hooks/useLocale';
interface Props {
status: ConnectionStatus;
}
type BannerState = 'hidden' | 'reconnecting' | 'reconnected';
export function ConnectionBanner({ status }: Props) {
const t = useT();
const [banner, setBanner] = useState<BannerState>('hidden');
const prevStatus = useRef(status);
const dismissTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
const prev = prevStatus.current;
prevStatus.current = status;
if (dismissTimer.current) {
clearTimeout(dismissTimer.current);
dismissTimer.current = null;
}
if (status === 'disconnected' || status === 'connecting') {
// Only show reconnecting if we were previously connected (not initial load)
if (prev === 'connected') {
setBanner('reconnecting');
}
} else if (status === 'connected' && prev !== 'connected') {
// Just reconnected — flash success only if we were showing the banner
if (banner === 'reconnecting' || prev === 'disconnected' || prev === 'connecting') {
setBanner('reconnected');
dismissTimer.current = setTimeout(() => setBanner('hidden'), 3000);
}
}
return () => {
if (dismissTimer.current) clearTimeout(dismissTimer.current);
};
}, [status]);
if (banner === 'hidden') return null;
const isReconnecting = banner === 'reconnecting';
return (
<div
role="alert"
aria-live="polite"
className={`flex items-center justify-center gap-2 px-4 py-2 text-xs font-medium transition-all duration-500 animate-in slide-in-from-top ${
isReconnecting
? 'bg-amber-500/10 text-amber-300 border-b border-amber-500/20'
: 'bg-emerald-500/10 text-emerald-300 border-b border-emerald-500/20'
}`}
>
{isReconnecting ? (
<>
<Loader2 size={14} className="animate-spin" />
<span>{t('connection.reconnecting')}</span>
</>
) : (
<>
<Wifi size={14} />
<span>{t('connection.reconnected')}</span>
</>
)}
</div>
);
}

View File

@@ -49,6 +49,10 @@ const en = {
// Tool call
'tool.result': 'Result',
// Connection banner
'connection.reconnecting': 'Connection lost — reconnecting…',
'connection.reconnected': 'Reconnected!',
// Timestamps
'time.yesterday': 'Yesterday',
} as const;
@@ -89,6 +93,9 @@ const fr: Record<keyof typeof en, string> = {
'tool.result': 'Résultat',
'connection.reconnecting': 'Connexion perdue — reconnexion…',
'connection.reconnected': 'Reconnecté !',
'time.yesterday': 'Hier',
};