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:
@@ -4,6 +4,7 @@ import { Header } from './components/Header';
|
|||||||
import { Sidebar } from './components/Sidebar';
|
import { Sidebar } from './components/Sidebar';
|
||||||
import { Chat } from './components/Chat';
|
import { Chat } from './components/Chat';
|
||||||
import { LoginScreen } from './components/LoginScreen';
|
import { LoginScreen } from './components/LoginScreen';
|
||||||
|
import { ConnectionBanner } from './components/ConnectionBanner';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const {
|
const {
|
||||||
@@ -38,6 +39,7 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
<div className="flex-1 flex flex-col min-w-0">
|
<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} />
|
<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} />
|
<Chat messages={messages} isGenerating={isGenerating} status={status} onSend={sendMessage} onAbort={abort} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
72
src/components/ConnectionBanner.tsx
Normal file
72
src/components/ConnectionBanner.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -49,6 +49,10 @@ const en = {
|
|||||||
// Tool call
|
// Tool call
|
||||||
'tool.result': 'Result',
|
'tool.result': 'Result',
|
||||||
|
|
||||||
|
// Connection banner
|
||||||
|
'connection.reconnecting': 'Connection lost — reconnecting…',
|
||||||
|
'connection.reconnected': 'Reconnected!',
|
||||||
|
|
||||||
// Timestamps
|
// Timestamps
|
||||||
'time.yesterday': 'Yesterday',
|
'time.yesterday': 'Yesterday',
|
||||||
} as const;
|
} as const;
|
||||||
@@ -89,6 +93,9 @@ const fr: Record<keyof typeof en, string> = {
|
|||||||
|
|
||||||
'tool.result': 'Résultat',
|
'tool.result': 'Résultat',
|
||||||
|
|
||||||
|
'connection.reconnecting': 'Connexion perdue — reconnexion…',
|
||||||
|
'connection.reconnected': 'Reconnecté !',
|
||||||
|
|
||||||
'time.yesterday': 'Hier',
|
'time.yesterday': 'Hier',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user